Initial specification, first pass implementation.

This commit is contained in:
Chris McCormick 2025-12-30 17:14:27 +08:00
commit c763681278
3 changed files with 1190 additions and 0 deletions

303
SPEC.md Normal file
View 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
View 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
View 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