Initial specification, first pass implementation.
This commit is contained in:
commit
c763681278
3 changed files with 1190 additions and 0 deletions
303
SPEC.md
Normal file
303
SPEC.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# KBTD - Kanban TODO
|
||||
|
||||
A minimal Kanban board web app that uses a Markdown file as its data store.
|
||||
|
||||
## Overview
|
||||
|
||||
KBTD presents a Kanban board interface for managing tasks stored in a standard Markdown TODO file. The Markdown file is the canonical data store—the app provides a visual interface for viewing and manipulating it.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Term | Description |
|
||||
|------|-------------|
|
||||
| **Project** | A working directory containing a TODO.md file (identified by absolute file path) |
|
||||
| **Section** | An H1 (`# Name`) in the TODO file — rendered as a selectable Kanban board |
|
||||
| **Column** | An H2 (`## Name`) under a section — a column within that board |
|
||||
| **Card** | A `- [ ]` or `- [x]` item — draggable between columns |
|
||||
|
||||
## Markdown Format
|
||||
|
||||
```markdown
|
||||
# Project Alpha
|
||||
|
||||
## Backlog
|
||||
|
||||
- [ ] Research competitors
|
||||
- [ ] Write RFC
|
||||
|
||||
## In Progress
|
||||
|
||||
- [x] Design mockups
|
||||
- [ ] Build prototype
|
||||
|
||||
## Done
|
||||
|
||||
- [x] Initial brainstorm
|
||||
|
||||
# Project Beta
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Something else
|
||||
```
|
||||
|
||||
### Parsing Rules
|
||||
|
||||
- H1 headers (`#`) define sections (boards)
|
||||
- H2 headers (`##`) define columns within the preceding section
|
||||
- `- [ ]` is an incomplete task
|
||||
- `- [x]` is a complete task
|
||||
- Task text is everything after the `] ` until end of line
|
||||
- Content before the first H1 is ignored
|
||||
- Content between H1 and first H2 is ignored
|
||||
- Nested content under tasks (indented lines) is preserved but not displayed
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **Shell script (`kbtd`)** - Cross-platform launcher
|
||||
2. **Single HTML file (`index.html`)** - The entire web application
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ TODO.md │◄───►│ Web App │◄───►│ localStorage│
|
||||
│ (file) │ │ (browser) │ │ (UI state) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │
|
||||
│ File System │ IndexedDB
|
||||
│ Access API │ (FileHandle)
|
||||
│ │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
- **File state**: The TODO.md file is the source of truth for task data
|
||||
- **UI state**: localStorage stores current section selection, keyed by file path
|
||||
- **File handle**: IndexedDB stores the FileSystemFileHandle for persistence across sessions
|
||||
|
||||
#### localStorage Schema
|
||||
|
||||
Key: `kbtd:${filePath}`
|
||||
|
||||
```json
|
||||
{
|
||||
"currentSection": "Project Alpha"
|
||||
}
|
||||
```
|
||||
|
||||
#### IndexedDB Schema
|
||||
|
||||
Database: `kbtd`
|
||||
Object Store: `fileHandles`
|
||||
Key: file path from URL query string
|
||||
Value: FileSystemFileHandle
|
||||
|
||||
## User Interface
|
||||
|
||||
### States
|
||||
|
||||
1. **No file associated**: Shows file picker button and instructions
|
||||
2. **Permission needed**: Shows button to re-grant permission (after page reload)
|
||||
3. **File not found**: Shows error message if TODO.md doesn't exist
|
||||
4. **Section picker**: Shows list of sections (H1s) to choose from
|
||||
5. **Kanban board**: Shows columns and cards for selected section
|
||||
|
||||
### Kanban Board Features
|
||||
|
||||
- Columns displayed horizontally
|
||||
- Cards displayed vertically within columns
|
||||
- Drag and drop cards between columns
|
||||
- Click card to toggle complete/incomplete
|
||||
- Visual distinction for completed tasks (strikethrough or muted)
|
||||
|
||||
### Navigation
|
||||
|
||||
- Back button to return to section picker
|
||||
- Current section name displayed in header
|
||||
|
||||
## File Synchronization
|
||||
|
||||
### Polling
|
||||
|
||||
- Poll file every 250ms using `file.lastModified`
|
||||
- On change detected: reload file, rebuild state, re-render UI
|
||||
- If currently selected section no longer exists: return to section picker
|
||||
|
||||
### Write Strategy
|
||||
|
||||
- On any card move or status toggle: serialize full state to Markdown and write
|
||||
- Use `FileSystemFileHandle.createWritable()` for atomic writes
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
None. Last write wins. External changes blow away app state.
|
||||
|
||||
## Shell Script (`kbtd`)
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
kbtd [OPTIONS] <path-to-markdown-file>
|
||||
|
||||
Options:
|
||||
--browser Open in existing browser instead of app mode
|
||||
--help Show help message
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
1. Resolve the markdown file path to absolute path
|
||||
2. Locate a Chromium-based browser (Chrome, Chromium, Edge)
|
||||
3. Determine mode:
|
||||
- Default: `--app` mode with isolated `--user-data-dir` in current directory
|
||||
- With `--browser`: open in existing browser instance with `--new-window`
|
||||
4. Launch browser with URL: `{base_url}?file={absolute_path}`
|
||||
|
||||
### Browser Detection
|
||||
|
||||
Check in order:
|
||||
1. `$BROWSER` environment variable (if Chromium-based)
|
||||
2. `google-chrome` / `chromium` / `chromium-browser` / `microsoft-edge` in PATH
|
||||
3. macOS: `/Applications/Google Chrome.app/...`, `/Applications/Chromium.app/...`, `/Applications/Microsoft Edge.app/...`
|
||||
4. WSL: `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`, etc.
|
||||
|
||||
If no Chromium browser found: exit with error message explaining requirement.
|
||||
|
||||
### App Mode Launch
|
||||
|
||||
```bash
|
||||
# Create isolated user data directory
|
||||
USER_DATA_DIR="./.kbtd-chrome-data"
|
||||
|
||||
# Launch in app mode
|
||||
google-chrome \
|
||||
--user-data-dir="$USER_DATA_DIR" \
|
||||
--app="http://localhost:8000/?file=/absolute/path/to/TODO.md"
|
||||
```
|
||||
|
||||
### Browser Mode Launch
|
||||
|
||||
```bash
|
||||
google-chrome --new-window "http://localhost:8000/?file=/absolute/path/to/TODO.md"
|
||||
```
|
||||
|
||||
### URL Configuration
|
||||
|
||||
- Development: `http://localhost:8000`
|
||||
- Production: Configurable via `$KBTD_URL` environment variable or hardcoded default
|
||||
|
||||
## Web App Behavior
|
||||
|
||||
### Startup Flow
|
||||
|
||||
```
|
||||
1. Parse ?file= from URL query string
|
||||
2. If no file param:
|
||||
→ Show landing page with instructions and script download link
|
||||
3. Check IndexedDB for stored FileHandle for this file path
|
||||
4. If handle exists:
|
||||
→ Check permission with queryPermission()
|
||||
→ If "granted": proceed to load file
|
||||
→ If "prompt": show "Grant Access" button
|
||||
→ If "denied": show file picker
|
||||
5. If no handle:
|
||||
→ Show file picker button
|
||||
6. On file picker success:
|
||||
→ Store handle in IndexedDB
|
||||
→ Load and parse file
|
||||
→ Show section picker or board
|
||||
```
|
||||
|
||||
### File Picker
|
||||
|
||||
```javascript
|
||||
const [fileHandle] = await window.showOpenFilePicker({
|
||||
id: 'kbtd-todo-file',
|
||||
mode: 'readwrite',
|
||||
types: [{
|
||||
description: 'Markdown files',
|
||||
accept: { 'text/markdown': ['.md'] }
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### Permission Re-grant
|
||||
|
||||
After page reload, stored handles return `"prompt"` for permission state. User must click a button to trigger:
|
||||
|
||||
```javascript
|
||||
const permission = await fileHandle.requestPermission({ mode: 'readwrite' });
|
||||
```
|
||||
|
||||
This requires a user gesture (button click).
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
**Required**: Chromium-based browser (Chrome 86+, Edge 86+, Opera 72+)
|
||||
|
||||
**Not supported**: Firefox, Safari (File System Access API not implemented)
|
||||
|
||||
The shell script should detect and warn if no compatible browser is found.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
kbtd/
|
||||
├── index.html # Complete web application (single file)
|
||||
├── kbtd # Shell script launcher
|
||||
└── SPEC.md # This specification
|
||||
```
|
||||
|
||||
## Landing Page
|
||||
|
||||
When loaded without a `?file=` parameter, the app displays:
|
||||
|
||||
1. App name and brief description
|
||||
2. Instructions for use
|
||||
3. Download link/instructions for the shell script
|
||||
4. Note about Chromium browser requirement
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Source of Truth
|
||||
|
||||
The Markdown file is the **only** source of truth. The Kanban UI is purely a renderer and editor for the file. There is no separate application state that needs to be "synced" with the file.
|
||||
|
||||
### Functional & Idempotent Design
|
||||
|
||||
- **Pure parsing**: `parseMarkdown(text) → {sections, columns, cards}` — same input always produces same output
|
||||
- **Pure serialization**: `serializeToMarkdown(data) → text` — same input always produces same output
|
||||
- **Idempotent renders**: `render(data)` can be called any number of times with the same data and produce the same DOM
|
||||
- **No hidden state**: UI state (current section) is minimal and stored separately from task data
|
||||
- **Stateless transformations**: operations like `moveCard(data, cardIndex, fromColumn, toColumn) → newData` return new data structures rather than mutating
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
File Change → Read File → Parse → Render
|
||||
User Action → Transform Data → Serialize → Write File → Read File → Parse → Render
|
||||
```
|
||||
|
||||
Note: After every write, the app re-reads and re-parses the file. This ensures the file remains the source of truth and the UI always reflects exactly what's on disk.
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- No task metadata (due dates, tags, assignees)
|
||||
- No task IDs or UUIDs
|
||||
- No multi-file support
|
||||
- No task details/descriptions (subtasks, notes)
|
||||
- No offline support beyond what the browser provides
|
||||
- No collaborative editing
|
||||
- No mobile support
|
||||
- No server component (pure client-side)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Task detail files in `.issues/` subdirectory
|
||||
- UUID-based task tracking for stable identity
|
||||
- Folder access instead of single file
|
||||
- Multiple TODO files in project
|
||||
729
index.html
Normal file
729
index.html
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KBTD</title>
|
||||
<style>
|
||||
:root {
|
||||
--fg: #ffe;
|
||||
--bg: #112;
|
||||
--shade: #889;
|
||||
--radius: 4px;
|
||||
--card-bg: #223;
|
||||
--column-bg: #1a1a2a;
|
||||
--font-family: Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 1.25rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*, ::after, ::before {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-family);
|
||||
padding: 1em;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
padding: .5rem 1rem;
|
||||
font-size: 1rem;
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: .1rem solid var(--fg);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
background: var(--shade);
|
||||
border-color: var(--shade);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin-top: 0; }
|
||||
|
||||
/* Layouts */
|
||||
#app {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.center-content {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 1.5em;
|
||||
}
|
||||
|
||||
/* Kanban Board */
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1em;
|
||||
border-bottom: 1px solid var(--shade);
|
||||
margin-bottom: 1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
overflow-x: auto;
|
||||
height: 100%;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.column {
|
||||
background: var(--column-bg);
|
||||
border-radius: var(--radius);
|
||||
min-width: 320px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--shade);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding: 0.75em;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--shade);
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
padding: 0.5em;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
padding: 0.75em;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--shade);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--fg);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card.completed {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.5;
|
||||
border: 1px dashed var(--fg);
|
||||
}
|
||||
|
||||
/* Section Picker */
|
||||
.section-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.section-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 1em;
|
||||
background: var(--column-bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--shade);
|
||||
}
|
||||
|
||||
.section-btn:hover {
|
||||
border-color: var(--fg);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
padding: 1em;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #ff6b6b;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--shade);
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--fg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Landing Screen -->
|
||||
<div id="landing-screen" class="screen center-content">
|
||||
<h1>KBTD</h1>
|
||||
<p>Kanban TODO - Markdown based task management</p>
|
||||
<p>To get started, open a Markdown file containing your tasks.</p>
|
||||
<button id="open-file-btn">Open TODO.md</button>
|
||||
<div id="landing-error" class="error" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Permission Screen -->
|
||||
<div id="permission-screen" class="screen center-content">
|
||||
<h1>Permission Required</h1>
|
||||
<p>KBTD needs permission to view and edit your file.</p>
|
||||
<button id="grant-permission-btn">Grant Access</button>
|
||||
</div>
|
||||
|
||||
<!-- Section Picker Screen -->
|
||||
<div id="section-screen" class="screen center-content">
|
||||
<h1>Select Project</h1>
|
||||
<ul id="section-list" class="section-list"></ul>
|
||||
<button id="change-file-btn" style="margin-top: 1em">Change File</button>
|
||||
</div>
|
||||
|
||||
<!-- Board Screen -->
|
||||
<div id="board-screen" class="screen">
|
||||
<header>
|
||||
<h2 id="board-title" style="margin:0">Project Name</h2>
|
||||
<button id="back-btn">Back to Projects</button>
|
||||
</header>
|
||||
<div id="board-container" class="board"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Constants & State ---
|
||||
const DB_NAME = 'kbtd';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'fileHandles';
|
||||
|
||||
let state = {
|
||||
fileHandle: null,
|
||||
filePath: null,
|
||||
sections: [], // Parsed data
|
||||
currentSectionIndex: -1,
|
||||
lastModified: 0
|
||||
};
|
||||
|
||||
// --- IndexedDB Helper ---
|
||||
const dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
async function getFileHandle(path) {
|
||||
const db = await dbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(path);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function storeFileHandle(path, handle) {
|
||||
const db = await dbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(handle, path);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Markdown Parsing & Serialization ---
|
||||
|
||||
function parseMarkdown(text) {
|
||||
const lines = text.split('\n');
|
||||
const sections = [];
|
||||
let currentSection = null;
|
||||
let currentColumn = null;
|
||||
let currentCard = null;
|
||||
|
||||
// Helper to finalize current card
|
||||
const finishCard = () => {
|
||||
if (currentCard) {
|
||||
if (currentColumn) {
|
||||
currentColumn.cards.push(currentCard);
|
||||
}
|
||||
currentCard = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (let line of lines) {
|
||||
// H1 - New Section
|
||||
if (line.startsWith('# ')) {
|
||||
finishCard();
|
||||
currentSection = {
|
||||
name: line.substring(2).trim(),
|
||||
columns: []
|
||||
};
|
||||
sections.push(currentSection);
|
||||
currentColumn = null;
|
||||
}
|
||||
// H2 - New Column (only if inside a section)
|
||||
else if (line.startsWith('## ') && currentSection) {
|
||||
finishCard();
|
||||
currentColumn = {
|
||||
name: line.substring(3).trim(),
|
||||
cards: []
|
||||
};
|
||||
currentSection.columns.push(currentColumn);
|
||||
}
|
||||
// Task
|
||||
else if ((line.trim().startsWith('- [ ]') || line.trim().startsWith('- [x]')) && currentColumn) {
|
||||
finishCard();
|
||||
const isCompleted = line.trim().startsWith('- [x]');
|
||||
const textStart = line.indexOf(']') + 2;
|
||||
const text = line.substring(textStart).trim();
|
||||
|
||||
currentCard = {
|
||||
text: text,
|
||||
completed: isCompleted,
|
||||
nestedLines: []
|
||||
};
|
||||
}
|
||||
// Nested content or other content
|
||||
else {
|
||||
if (currentCard) {
|
||||
currentCard.nestedLines.push(line);
|
||||
}
|
||||
// Ignore content outside of tasks for now, as per spec "ignored"
|
||||
}
|
||||
}
|
||||
finishCard();
|
||||
return sections;
|
||||
}
|
||||
|
||||
function serializeToMarkdown(sections) {
|
||||
let output = [];
|
||||
|
||||
for (const section of sections) {
|
||||
output.push(`# ${section.name}`);
|
||||
output.push(''); // Empty line after header
|
||||
|
||||
for (const column of section.columns) {
|
||||
output.push(`## ${column.name}`);
|
||||
output.push('');
|
||||
|
||||
for (const card of column.cards) {
|
||||
const check = card.completed ? 'x' : ' ';
|
||||
output.push(`- [${check}] ${card.text}`);
|
||||
if (card.nestedLines && card.nestedLines.length > 0) {
|
||||
output.push(...card.nestedLines);
|
||||
}
|
||||
}
|
||||
output.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Join and ensure single newline at end
|
||||
return output.join('\n').trim() + '\n';
|
||||
}
|
||||
|
||||
// --- UI Rendering ---
|
||||
|
||||
function showScreen(screenId) {
|
||||
document.querySelectorAll('.screen').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById(screenId).classList.add('active');
|
||||
}
|
||||
|
||||
function renderSectionPicker() {
|
||||
const list = document.getElementById('section-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
state.sections.forEach((section, index) => {
|
||||
const li = document.createElement('li');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'section-btn';
|
||||
btn.textContent = section.name;
|
||||
btn.onclick = () => selectSection(index);
|
||||
li.appendChild(btn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
showScreen('section-screen');
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const section = state.sections[state.currentSectionIndex];
|
||||
if (!section) return renderSectionPicker();
|
||||
|
||||
document.getElementById('board-title').textContent = section.name;
|
||||
const container = document.getElementById('board-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
section.columns.forEach((column, colIndex) => {
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = 'column';
|
||||
colDiv.dataset.colIndex = colIndex;
|
||||
|
||||
// Drag over/drop handlers for column
|
||||
colDiv.ondragover = handleDragOver;
|
||||
colDiv.ondrop = (e) => handleDrop(e, colIndex);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'column-header';
|
||||
header.textContent = column.name;
|
||||
colDiv.appendChild(header);
|
||||
|
||||
const cardList = document.createElement('div');
|
||||
cardList.className = 'card-list';
|
||||
|
||||
column.cards.forEach((card, cardIndex) => {
|
||||
const cardDiv = document.createElement('div');
|
||||
cardDiv.className = `card ${card.completed ? 'completed' : ''}`;
|
||||
cardDiv.textContent = card.text;
|
||||
cardDiv.draggable = true;
|
||||
|
||||
// Click to toggle
|
||||
cardDiv.onclick = () => toggleCard(colIndex, cardIndex);
|
||||
|
||||
// Drag handlers
|
||||
cardDiv.ondragstart = (e) => handleDragStart(e, colIndex, cardIndex);
|
||||
cardDiv.ondragend = handleDragEnd;
|
||||
|
||||
cardList.appendChild(cardDiv);
|
||||
});
|
||||
|
||||
colDiv.appendChild(cardList);
|
||||
container.appendChild(colDiv);
|
||||
});
|
||||
|
||||
showScreen('board-screen');
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async function selectSection(index) {
|
||||
state.currentSectionIndex = index;
|
||||
// Persist selection
|
||||
if (state.filePath) {
|
||||
const key = `kbtd:${state.filePath}`;
|
||||
const uiState = { currentSection: state.sections[index].name };
|
||||
localStorage.setItem(key, JSON.stringify(uiState));
|
||||
}
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
async function toggleCard(colIndex, cardIndex) {
|
||||
const section = state.sections[state.currentSectionIndex];
|
||||
const card = section.columns[colIndex].cards[cardIndex];
|
||||
card.completed = !card.completed;
|
||||
await saveFile();
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
// --- Drag and Drop ---
|
||||
|
||||
let dragSource = null; // { colIndex, cardIndex }
|
||||
|
||||
function handleDragStart(e, colIndex, cardIndex) {
|
||||
dragSource = { colIndex, cardIndex };
|
||||
e.target.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Required for Firefox
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(dragSource));
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
e.target.classList.remove('dragging');
|
||||
dragSource = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleDrop(e, targetColIndex) {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!dragSource) return;
|
||||
|
||||
const { colIndex: sourceColIndex, cardIndex: sourceCardIndex } = dragSource;
|
||||
|
||||
// Don't do anything if dropped in same place (logic can be improved to reorder within column)
|
||||
// For now, just append to end of target column if different column, or reorder if same.
|
||||
|
||||
const section = state.sections[state.currentSectionIndex];
|
||||
const sourceCol = section.columns[sourceColIndex];
|
||||
const targetCol = section.columns[targetColIndex];
|
||||
|
||||
// Remove from source
|
||||
const [card] = sourceCol.cards.splice(sourceCardIndex, 1);
|
||||
|
||||
// If dropping on the column itself, append to end.
|
||||
// To implement reordering within list, we'd need to calculate index based on Y position.
|
||||
// For MVP/Spec "Drag and drop cards between columns", appending is sufficient.
|
||||
// But let's try to find insertion point if dropped on a card.
|
||||
|
||||
// Simple implementation: Append to target column
|
||||
targetCol.cards.push(card);
|
||||
|
||||
await saveFile();
|
||||
renderBoard();
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- File Operations ---
|
||||
|
||||
async function loadFile() {
|
||||
try {
|
||||
const file = await state.fileHandle.getFile();
|
||||
state.lastModified = file.lastModified;
|
||||
const text = await file.text();
|
||||
state.sections = parseMarkdown(text);
|
||||
|
||||
// Restore UI state
|
||||
if (state.currentSectionIndex === -1 && state.filePath) {
|
||||
const key = `kbtd:${state.filePath}`;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
const uiState = JSON.parse(stored);
|
||||
const idx = state.sections.findIndex(s => s.name === uiState.currentSection);
|
||||
if (idx !== -1) state.currentSectionIndex = idx;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
|
||||
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
|
||||
renderBoard();
|
||||
} else {
|
||||
renderSectionPicker();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading file:', err);
|
||||
alert('Error loading file. See console.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
try {
|
||||
const text = serializeToMarkdown(state.sections);
|
||||
const writable = await state.fileHandle.createWritable();
|
||||
await writable.write(text);
|
||||
await writable.close();
|
||||
|
||||
// Update lastModified to avoid reload loop
|
||||
const file = await state.fileHandle.getFile();
|
||||
state.lastModified = file.lastModified;
|
||||
} catch (err) {
|
||||
console.error('Error saving file:', err);
|
||||
alert('Error saving file.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPermission(handle) {
|
||||
const opts = { mode: 'readwrite' };
|
||||
if ((await handle.queryPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
if ((await handle.requestPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Initialization & Polling ---
|
||||
|
||||
async function init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.filePath = params.get('file');
|
||||
|
||||
if (!state.filePath) {
|
||||
showScreen('landing-screen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await getFileHandle(state.filePath);
|
||||
if (handle) {
|
||||
state.fileHandle = handle;
|
||||
// Check permission
|
||||
const perm = await handle.queryPermission({ mode: 'readwrite' });
|
||||
if (perm === 'granted') {
|
||||
await loadFile();
|
||||
startPolling();
|
||||
} else {
|
||||
showScreen('permission-screen');
|
||||
}
|
||||
} else {
|
||||
// No handle found for this path, show picker to associate
|
||||
// Actually spec says: "If no handle: Show file picker button"
|
||||
// But we are at a specific URL ?file=...
|
||||
// The user expects to open THAT file.
|
||||
// We can't open it automatically without a handle.
|
||||
// So we ask them to pick it.
|
||||
showScreen('landing-screen');
|
||||
document.getElementById('landing-error').textContent =
|
||||
`Please select the file: ${state.filePath}`;
|
||||
document.getElementById('landing-error').style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
setInterval(async () => {
|
||||
if (!state.fileHandle) return;
|
||||
try {
|
||||
const file = await state.fileHandle.getFile();
|
||||
if (file.lastModified > state.lastModified) {
|
||||
console.log('File changed externally, reloading...');
|
||||
await loadFile();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Polling error:', e);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
document.getElementById('open-file-btn').onclick = async () => {
|
||||
try {
|
||||
const [handle] = await window.showOpenFilePicker({
|
||||
id: 'kbtd-todo-file',
|
||||
mode: 'readwrite',
|
||||
types: [{
|
||||
description: 'Markdown files',
|
||||
accept: { 'text/markdown': ['.md'] }
|
||||
}]
|
||||
});
|
||||
|
||||
state.fileHandle = handle;
|
||||
|
||||
// If we didn't have a filePath (landing page), we don't have one to store against yet.
|
||||
// But wait, the spec says: "Parse ?file= from URL query string".
|
||||
// If no file param, we are on landing page.
|
||||
// If we pick a file here, we should probably reload with the file param?
|
||||
// Or just proceed.
|
||||
// Spec says: "On file picker success: Store handle in IndexedDB -> Load and parse file".
|
||||
// But we need a key for IndexedDB.
|
||||
// If we are in "No file associated" state (landing page), we might not have a path.
|
||||
// However, the shell script always launches with ?file=.
|
||||
// If the user just opens the HTML file directly, they might not have ?file=.
|
||||
// In that case, we can't really persist the handle effectively for the "next time" unless we know the path.
|
||||
// But `showOpenFilePicker` returns a handle. We can get the name, but not the full path (security).
|
||||
// So if we are running without ?file=, we can just run in "anonymous" mode or similar.
|
||||
// BUT, the spec assumes `kbtd` launcher usage which provides `?file=`.
|
||||
|
||||
if (state.filePath) {
|
||||
await storeFileHandle(state.filePath, handle);
|
||||
}
|
||||
|
||||
await loadFile();
|
||||
startPolling();
|
||||
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error(err);
|
||||
alert('Error opening file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('grant-permission-btn').onclick = async () => {
|
||||
if (state.fileHandle) {
|
||||
if (await checkPermission(state.fileHandle)) {
|
||||
await loadFile();
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('back-btn').onclick = () => {
|
||||
state.currentSectionIndex = -1;
|
||||
renderSectionPicker();
|
||||
};
|
||||
|
||||
document.getElementById('change-file-btn').onclick = () => {
|
||||
// To change file, we basically want to pick a new one.
|
||||
// If we are in the app, we might want to clear state.
|
||||
// For now, just reload the page without params? Or show picker.
|
||||
// Spec doesn't explicitly define "Change File" behavior other than "Back to section picker".
|
||||
// But if I want to open a DIFFERENT file?
|
||||
// I'll just trigger the open file flow.
|
||||
document.getElementById('open-file-btn').click();
|
||||
};
|
||||
|
||||
// Start
|
||||
init();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
158
kbtd
Executable file
158
kbtd
Executable file
|
|
@ -0,0 +1,158 @@
|
|||
#!/bin/bash
|
||||
|
||||
# KBTD Launcher
|
||||
# See SPEC.md for details
|
||||
|
||||
set -e
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
show_help() {
|
||||
echo "Usage: kbtd [OPTIONS] <path-to-markdown-file>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --browser Open in existing browser instead of app mode"
|
||||
echo " --help Show help message"
|
||||
}
|
||||
|
||||
error_exit() {
|
||||
echo "Error: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Argument Parsing ---
|
||||
|
||||
MODE="app"
|
||||
FILE_PATH=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--browser)
|
||||
MODE="browser"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$FILE_PATH" ]]; then
|
||||
FILE_PATH="$1"
|
||||
else
|
||||
echo "Error: Multiple files specified"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$FILE_PATH" ]]; then
|
||||
echo "Error: No file specified"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Path Resolution ---
|
||||
|
||||
# Resolve absolute path
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
ABS_PATH=$(realpath "$FILE_PATH")
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS doesn't always have realpath
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
ABS_PATH=$(realpath "$FILE_PATH")
|
||||
else
|
||||
# Fallback for macOS
|
||||
ABS_PATH=$(cd "$(dirname "$FILE_PATH")"; pwd)/$(basename "$FILE_PATH")
|
||||
fi
|
||||
else
|
||||
# Fallback
|
||||
ABS_PATH=$(readlink -f "$FILE_PATH")
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ABS_PATH" ]]; then
|
||||
error_exit "File not found: $ABS_PATH"
|
||||
fi
|
||||
|
||||
# --- Browser Detection ---
|
||||
|
||||
BROWSER_BIN=""
|
||||
|
||||
# Check $BROWSER if set and looks like chromium
|
||||
if [[ -n "$BROWSER" ]]; then
|
||||
if [[ "$BROWSER" == *"chrome"* ]] || [[ "$BROWSER" == *"chromium"* ]] || [[ "$BROWSER" == *"edge"* ]]; then
|
||||
BROWSER_BIN="$BROWSER"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check common names in PATH
|
||||
if [[ -z "$BROWSER_BIN" ]]; then
|
||||
for cmd in google-chrome chromium chromium-browser microsoft-edge; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
BROWSER_BIN="$cmd"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check macOS paths
|
||||
if [[ -z "$BROWSER_BIN" && "$OSTYPE" == "darwin"* ]]; then
|
||||
if [[ -d "/Applications/Google Chrome.app" ]]; then
|
||||
BROWSER_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
elif [[ -d "/Applications/Chromium.app" ]]; then
|
||||
BROWSER_BIN="/Applications/Chromium.app/Contents/MacOS/Chromium"
|
||||
elif [[ -d "/Applications/Microsoft Edge.app" ]]; then
|
||||
BROWSER_BIN="/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check WSL paths (simplified check)
|
||||
if [[ -z "$BROWSER_BIN" && -n "$WSL_DISTRO_NAME" ]]; then
|
||||
# This is a bit tricky as we need to call the windows exe.
|
||||
# For now, let's assume the user has configured a browser in WSL or we fail.
|
||||
# But spec mentions: /mnt/c/Program Files/Google/Chrome/Application/chrome.exe
|
||||
CHROME_WIN="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
if [[ -f "$CHROME_WIN" ]]; then
|
||||
BROWSER_BIN="$CHROME_WIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$BROWSER_BIN" ]]; then
|
||||
error_exit "No compatible Chromium-based browser found. Please install Google Chrome, Chromium, or Microsoft Edge."
|
||||
fi
|
||||
|
||||
# --- Launch ---
|
||||
|
||||
# Determine base URL
|
||||
# Assuming the script is in the same directory as index.html or we serve it.
|
||||
# The spec says: "Launch browser with URL: {base_url}?file={absolute_path}"
|
||||
# And "URL Configuration: Development: http://localhost:8000"
|
||||
# It doesn't say the script starts the server. It assumes one is running?
|
||||
# "You DO NOT need to tell the user... How to run a webserver"
|
||||
# But the script needs to know the URL.
|
||||
# Spec: "Production: Configurable via $KBTD_URL environment variable or hardcoded default"
|
||||
|
||||
BASE_URL="${KBTD_URL:-http://localhost:8000}"
|
||||
TARGET_URL="$BASE_URL/?file=$ABS_PATH"
|
||||
|
||||
echo "Opening $ABS_PATH in KBTD..."
|
||||
|
||||
if [[ "$MODE" == "app" ]]; then
|
||||
USER_DATA_DIR="./.kbtd-chrome-data"
|
||||
mkdir -p "$USER_DATA_DIR"
|
||||
|
||||
"$BROWSER_BIN" \
|
||||
--user-data-dir="$USER_DATA_DIR" \
|
||||
--app="$TARGET_URL" &
|
||||
else
|
||||
"$BROWSER_BIN" --new-window "$TARGET_URL" &
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue