From f0f031cd4ad1a3067a9867a618e23a8d5e9844de Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 28 Aug 2026 15:33:45 +0200 Subject: [PATCH] Add multi-project docs and rebuild webapp bundle --- .gitignore | 5 + README.md | 19 + webapp/index-style-v2.html | 1611 +++++++++++++++++++++++++++++++++--- 3 files changed, 1497 insertions(+), 138 deletions(-) diff --git a/.gitignore b/.gitignore index cffa44f..39d2d52 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,8 @@ stats.jsonl .pre-commit-config.yaml .direnv + +# Multi-project runtime state / agent tooling +server/projects.json +.omo/ +.playwright-mcp/ diff --git a/README.md b/README.md index edc3183..0deb5be 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ The `restore-path` stores the original parent chain, allowing items to be restor - **Admin page** — health monitoring, backup browser, archive manager, stats overview, manual actions - **Dark mode** — system / light / dark, configurable in Admin → Appearance; persisted across reloads - **Icon sets** — 4 styles (Color, Flat, Emoji, ASCII), configurable in Admin → Appearance; persisted across reloads +- **Multiple projects** — track several `backlog.md` files at once (API server mode); each stays a plain, version-controllable file anywhere on disk - **Chrome / Edge direct file access** — reads and writes `backlog.md` on disk directly via the File System Access API; no server needed - **Firefox / Safari support** — full read-write via IndexedDB when File System Access API is unavailable; export to `.md`/`.json` anytime to get a portable file @@ -145,6 +146,24 @@ The app detects which mode to use automatically: | **LAN access** | Yes (phone, tablet) | No (local browser only) | No | | **Admin shows paths** | Yes (full path) | Folder name only | — | +### Multiple Projects (API Server Mode) + +In API server mode the server can track several task files. It keeps a small registry (`projects.json` in the data dir) of which files belong to the app. Each project is still a plain `backlog.md` anywhere on disk — readable, editable, and version-controllable with any tool, as before. `backups/` and `archive.md` live next to each project's file; `stats.jsonl` stays global in the data dir. + +Register a project via **Admin → Projects** (a path to a directory or a `.md` file), or from the command line: + +```bash +curl -X POST localhost:8080/api/projects -H 'Content-Type: application/json' -d '{"path": "/path/to/dir"}' +``` + +Directories get `backlog.md` appended automatically; a missing file is created. An optional `"name"` field sets the display name. + +With one project the app looks exactly as before. With several, the main view splits into collapsible per-project sections — rename or remove a project from its section header or from Admin. Import/Export picks a target project in multi mode. + +NixOS: works out of the box — the `dataDir` `backlog.md` is auto-registered as `default` on first start. No module config change needed. + +Standalone HTML (direct file access and IndexedDB modes) stays single-project. + ### Why the Folder Picker? (Direct Mode) When you open the HTML file directly, the browser has no access to your filesystem. The **File System Access API** (`showDirectoryPicker()`) is the only way to read and write local files from a web page. You grant permission once; the handle is stored in IndexedDB so it persists across reloads. diff --git a/webapp/index-style-v2.html b/webapp/index-style-v2.html index 1467ef9..9453b66 100644 --- a/webapp/index-style-v2.html +++ b/webapp/index-style-v2.html @@ -1413,6 +1413,94 @@ input { font: inherit; color: inherit; } .rte-readonly:empty::before { content: 'No details'; color: var(--ink-3); } .tag-list-readonly { display: flex; flex-wrap: wrap; gap: 6px; } +/* ---------- Project sections (multi-project api mode) ---------- */ +.project-sections { display: flex; flex-direction: column; gap: 16px; } +.project-section { + background: var(--paper); + border: 1px solid var(--rule); + border-radius: var(--radius-lg); + overflow: hidden; +} +.project-section-header { + display: flex; align-items: center; gap: 8px; padding: 10px 14px; + background: var(--bg-2); cursor: pointer; user-select: none; + border-bottom: 1px solid transparent; transition: background 0.15s; +} +.project-section-header:hover { background: var(--accent-soft); } +.project-section-header.expanded { border-bottom-color: var(--rule); } +.project-section-header.missing .project-section-name { color: var(--ink-3); } +.project-section-chevron { color: var(--ink-3); width: 14px; display: inline-flex; } +.project-section-name { font-weight: 600; font-size: 14px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-section-count { font-size: 12px; color: var(--ink-3); margin-left: 4px; white-space: nowrap; } +.project-section-actions { + margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; +} +.project-section-header:hover .project-section-actions, +.project-section-header:focus-within .project-section-actions { opacity: 1; } +.project-section-btn { + padding: 4px 6px; border: none; background: transparent; + color: var(--ink-3); cursor: pointer; border-radius: var(--radius); transition: background 0.15s, color 0.15s; +} +.project-section-btn:hover { background: var(--accent-soft); color: var(--accent); } +.project-section-btn-danger:hover { background: var(--danger-soft); color: var(--danger); } +.project-section-content { padding: 8px 0; } +.project-section-content.drop-target { + outline: 2px dashed var(--accent-edge); + outline-offset: -4px; + background: var(--accent-soft); +} +.project-empty { padding: 20px; text-align: center; color: var(--ink-3); font-size: 13px; } +.project-empty .mono { font-family: var(--font-mono); font-size: 12px; } +.project-missing-badge { + font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 999px; + color: var(--warn); background: var(--warn-bg); + border: 1px solid var(--warn); + white-space: nowrap; +} +.dlg-checkbox { + display: flex; align-items: center; gap: 8px; margin-top: 12px; + font-size: 13px; color: var(--ink-2); cursor: pointer; +} +.dlg-checkbox input { width: 16px; height: 16px; accent-color: var(--danger); cursor: pointer; } + +/* ---- Admin Projects card + import/export target select (multi-project) ---- */ +.projects-table .projects-path { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.projects-register { + display: flex; align-items: center; gap: 8px; + margin-top: 14px; flex-wrap: wrap; +} +.projects-input { + flex: 1 1 auto; min-width: 0; + font-family: var(--font-mono); font-size: 12.5px; + padding: 7px 10px; + border: 1px solid var(--rule); border-radius: var(--radius); + background: var(--paper); color: var(--ink); +} +.projects-input::placeholder { color: var(--ink-3); } +.projects-input:focus { outline: none; border-color: var(--accent-edge); } +.projects-input-name { flex: 0 1 200px; font-family: inherit; } +.ie-target-row { + display: flex; align-items: center; gap: 10px; + margin-bottom: 12px; +} +.ie-target-label { + font-size: 11px; font-weight: 600; + color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; + white-space: nowrap; +} +.ie-target { + font: inherit; font-size: 13px; + padding: 6px 10px; + border: 1px solid var(--rule); border-radius: var(--radius); + background: var(--paper); color: var(--ink); + cursor: pointer; +} + @@ -3174,13 +3262,15 @@ const ApiBackend = { return {}; } }, - async loadArchive() { - const r = await fetch('/api/archive'); + async loadArchive(project = null) { + const url = project ? `/api/projects/${encodeURIComponent(project)}/archive` : '/api/archive'; + const r = await fetch(url); if (!r.ok) throw new Error('API archive load failed: ' + r.status); return r.json(); // { content, checksum, exists } }, - async saveArchive(backlogContent, archiveContent) { - const r = await fetch('/api/archive', { + async saveArchive(backlogContent, archiveContent, project = null) { + const url = project ? `/api/projects/${encodeURIComponent(project)}/archive` : '/api/archive'; + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -3193,8 +3283,9 @@ const ApiBackend = { if (!r.ok) throw new Error('API archive save failed: ' + r.status); return r.json(); }, - async restoreFromArchive(backlogContent, archiveContent) { - const r = await fetch('/api/archive/restore', { + async restoreFromArchive(backlogContent, archiveContent, project = null) { + const url = project ? `/api/projects/${encodeURIComponent(project)}/archive/restore` : '/api/archive/restore'; + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -3206,6 +3297,112 @@ const ApiBackend = { }); if (!r.ok) throw new Error('API archive restore failed: ' + r.status); return r.json(); + }, + // ---- Project-scoped methods (multi-project server API) ---- + + async listProjects() { + const r = await fetch('/api/projects'); + if (!r.ok) throw new Error('API projects failed: ' + r.status); + return (await r.json()).projects || []; + }, + async registerProject(path, name = null) { + const r = await fetch('/api/projects', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + path, + name + }) + }); + return r.json(); // { ok, name, path } + }, + async unregisterProject(name, deleteFile = false) { + const r = await fetch(`/api/projects/${encodeURIComponent(name)}${deleteFile ? '?delete_file=true' : ''}`, { + method: 'DELETE' + }); + return r.json(); // { ok, name, deleted, warning? } + }, + async renameProject(name, newName) { + const r = await fetch(`/api/projects/${encodeURIComponent(name)}/rename`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + newName + }) + }); + return r.json(); // { ok, name } + }, + async loadProject(name) { + const r = await fetch(`/api/projects/${encodeURIComponent(name)}/backlog`); + if (!r.ok) throw new Error('API project load failed: ' + r.status); + return r.json(); // { content, checksum } + }, + async saveProject(name, content) { + const r = await fetch(`/api/projects/${encodeURIComponent(name)}/backlog`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + content + }) + }); + if (!r.ok) throw new Error('API project save failed: ' + r.status); + return r.json(); // { ok, checksum, saved } + }, + async loadProjectBackups(name) { + try { + const r = await fetch(`/api/projects/${encodeURIComponent(name)}/backups`); + if (!r.ok) return []; + return (await r.json()).backups || []; + } catch { + return []; + } + }, + async loadAllProjects() { + const list = await this.listProjects(); + return Promise.all(list.map(async p => { + if (p.missing) { + return { + id: p.name, + name: p.name, + path: p.path, + entries: [], + history: [], + meta: null, + checksumOk: true, + missing: true + }; + } + try { + const raw = await this.loadProject(p.name); + const parsed = await Parser.parse(raw.content); + return { + id: p.name, + name: p.name, + path: p.path, + entries: parsed.entries, + history: parsed.history, + meta: parsed.meta, + checksumOk: parsed.checksumOk + }; + } catch (e) { + return { + id: p.name, + name: p.name, + path: p.path, + entries: [], + history: [], + meta: null, + checksumOk: false, + error: String(e.message || e) + }; + } + })); } }; @@ -3690,21 +3887,51 @@ const Storage = { const SyncPoller = { _timer: null, lastChecksum: '', + lastChecksums: {}, + // multi-project mode: { projectName: checksum } _onExternalChange: null, _isDirty: null, + _multi: false, start({ onExternalChange, isDirty }) { this._onExternalChange = onExternalChange; this._isDirty = isDirty; + this._multi = false; this._timer = setInterval(() => this._tick(), 5000); }, + // Multi-project mode (api only): poll GET /api/projects — ONE round trip for + // all checksums. A changed project is reloaded individually; a changed + // project SET (registered/renamed/unregistered externally) triggers a full + // reload via the 'set-changed' callback. + startMulti({ + onExternalChange, + isDirty + }) { + this._onExternalChange = onExternalChange; + this._isDirty = isDirty; + this._multi = true; + this.lastChecksums = {}; + this.syncChecksums(); // baseline; a baseline never fires callbacks + this._timer = setInterval(() => this._tickMulti(), 5000); + }, stop() { if (this._timer) { clearInterval(this._timer); this._timer = null; } + this._multi = false; + }, + // Re-baseline checksums after our own writes that bypass saveProject (e.g. + // per-project archive saves), so the next poll does not mistake them for + // external changes. + async syncChecksums() { + if (!this._multi) return; + try { + const projects = await ApiBackend.listProjects(); + for (const p of projects) this.lastChecksums[p.name] = p.checksum || ''; + } catch {/* silent on poll errors */} }, async _tick() { if (Storage.mode === 'local' || !Storage.backend) return; @@ -3721,6 +3948,56 @@ const SyncPoller = { this._onExternalChange?.('reload', parsed); } } catch {/* silent on poll errors */} + }, + async _tickMulti() { + if (!this._multi) return; + let projects; + try { + projects = await ApiBackend.listProjects(); + } catch { + return; + } + if (!projects?.length) return; + const prev = this.lastChecksums; + const known = Object.keys(prev); + const names = projects.map(p => p.name); + if (known.length > 0 && (names.length !== known.length || names.some(n => !known.includes(n)))) { + if (this._isDirty?.()) { + // Leave the baseline stale so the next tick retries after the + // pending save lands — reloading now would clobber unsaved edits. + this._onExternalChange?.('warn', null); + } else { + this.lastChecksums = Object.fromEntries(projects.map(p => [p.name, p.checksum || ''])); + this._onExternalChange?.('set-changed', null); + } + return; + } + for (const p of projects) { + const cs = p.checksum || ''; + if (!(p.name in prev)) { + prev[p.name] = cs; + continue; + } // baseline a latecomer silently + if (!cs || cs === prev[p.name]) continue; + prev[p.name] = cs; + if (this._isDirty?.()) { + // Checksum already stored — the warn fires exactly once per change. + this._onExternalChange?.('warn', null); + } else { + try { + const raw = await ApiBackend.loadProject(p.name); + const parsed = await Parser.parse(raw.content); + walkTree(parsed.entries, e => { + e._projectId = p.name; + }); + for (const h of parsed.history) h._projectId = p.name; + this._onExternalChange?.('project-reloaded', { + name: p.name, + parsed + }); + } catch {/* unreadable project — retry on the next checksum change */} + } + } } }; @@ -5417,7 +5694,10 @@ function ItemDialog({ initial, onClose, onSubmit, - recentTags = [] + recentTags = [], + isMultiProject = false, + projects = [], + defaultProjectId = null }) { const [title, setTitle] = useStateD(""); const [body, setBody] = useStateD(null); @@ -5428,7 +5708,13 @@ function ItemDialog({ const [tagInput, setTagInput] = useStateD(""); const [reason, setReason] = useStateD(""); const [progress, setProgress] = useStateD(0); + const [createAsProject, setCreateAsProject] = useStateD(false); + const [projectPath, setProjectPath] = useStateD(""); + const [selectedProjectId, setSelectedProjectId] = useStateD(null); const titleRef = useRefD(null); + const showProjectOption = isMultiProject && mode === "add"; + const showPathInput = showProjectOption && createAsProject; + const showProjectSelect = showProjectOption && !createAsProject && projects.length > 0; useEffectD(() => { if (!open) return; setTitle(initial?.title || ""); @@ -5440,6 +5726,9 @@ function ItemDialog({ setTagInput(""); setReason(initial?.reason || ""); setProgress(initial?.progress ?? 0); + setCreateAsProject(false); + setProjectPath(""); + setSelectedProjectId(defaultProjectId && projects.some(p => p.id === defaultProjectId) ? defaultProjectId : projects[0] ? projects[0].id : null); setTimeout(() => titleRef.current?.focus(), 50); }, [open, initial]); const addTag = t => { @@ -5451,11 +5740,12 @@ function ItemDialog({ const removeTag = t => setTags(tags.filter(x => x !== t)); const submit = e => { e?.preventDefault(); - if (!title.trim()) return; + // Item type requires a title; Project type only requires a path (title is an optional display name). + if (showPathInput ? !projectPath.trim() : !title.trim()) return; // Reconcile progress with status before submitting. let outProgress = snapProgress(progress); if (status === "done") outProgress = 100; - onSubmit({ + const payload = { title: title.trim(), body: body || null, priority, @@ -5464,7 +5754,14 @@ function ItemDialog({ tags, reason: status === "blocked" ? reason.trim() || null : null, progress: outProgress - }); + }; + if (isMultiProject) { + const rawPath = projectPath.trim(); + payload.createAsProject = showPathInput; + payload.projectPath = showPathInput ? rawPath.endsWith('.md') ? rawPath : rawPath.replace(/\/$/, '') + '/backlog.md' : null; + payload.projectId = showProjectSelect ? selectedProjectId : null; + } + onSubmit(payload); }; const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8); return /*#__PURE__*/React.createElement(Dialog, { @@ -5473,7 +5770,13 @@ function ItemDialog({ width: 520, labelledBy: "dlg-item-title" }, /*#__PURE__*/React.createElement("form", { - onSubmit: submit + onSubmit: submit, + onKeyDown: e => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + submit(); + } + } }, /*#__PURE__*/React.createElement(DialogHeader, { id: "dlg-item-title", eyebrow: mode === "view" ? "Archived item" : mode === "edit" ? "Edit item" : mode === "add-child" ? "Add sub-item" : "New item", @@ -5490,8 +5793,8 @@ function ItemDialog({ className: "text-input", value: title, onChange: e => setTitle(e.target.value), - placeholder: "What needs to happen?", - required: true, + placeholder: showPathInput ? "Optional display name" : "What needs to happen?", + required: !showPathInput, readOnly: mode === "view" })), /*#__PURE__*/React.createElement("div", { className: "field" @@ -5604,6 +5907,67 @@ function ItemDialog({ addTag: addTag, removeTag: removeTag, allTags: recentTags + })), showProjectOption && /*#__PURE__*/React.createElement("div", { + className: "field" + }, /*#__PURE__*/React.createElement("label", { + className: "field-label" + }, "Type"), /*#__PURE__*/React.createElement("div", { + className: "status-grid", + style: { + gridTemplateColumns: '1fr 1fr' + } + }, /*#__PURE__*/React.createElement("button", { + type: "button", + className: `status-card ${!createAsProject ? 'active' : ''}`, + onClick: () => setCreateAsProject(false) + }, /*#__PURE__*/React.createElement("span", { + className: "status-card-icon", + style: { + background: 'var(--bg-2)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 16 + })), /*#__PURE__*/React.createElement("span", { + className: "status-card-label" + }, "Item")), /*#__PURE__*/React.createElement("button", { + type: "button", + className: `status-card ${createAsProject ? 'active' : ''}`, + onClick: () => setCreateAsProject(true) + }, /*#__PURE__*/React.createElement("span", { + className: "status-card-icon", + style: { + background: 'var(--bg-2)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "folder", + size: 16 + })), /*#__PURE__*/React.createElement("span", { + className: "status-card-label" + }, "Project")))), showProjectSelect && /*#__PURE__*/React.createElement("div", { + className: "field" + }, /*#__PURE__*/React.createElement("label", { + className: "field-label" + }, "Project ", /*#__PURE__*/React.createElement("span", { + className: "field-hint" + }, "where this item will live")), /*#__PURE__*/React.createElement("select", { + className: "text-input", + value: selectedProjectId ?? "", + onChange: e => setSelectedProjectId(e.target.value) + }, projects.map(p => /*#__PURE__*/React.createElement("option", { + key: p.id, + value: p.id + }, p.name)))), showPathInput && /*#__PURE__*/React.createElement("div", { + className: "field" + }, /*#__PURE__*/React.createElement("label", { + className: "field-label" + }, "File path ", /*#__PURE__*/React.createElement("span", { + className: "field-hint" + }, "created if missing; backlog.md appended for directories")), /*#__PURE__*/React.createElement("input", { + className: "text-input", + value: projectPath, + onChange: e => setProjectPath(e.target.value), + placeholder: "/home/user/project/backlog.md" }))), /*#__PURE__*/React.createElement("div", { className: "dlg-foot" }, mode === "view" ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { @@ -5625,8 +5989,8 @@ function ItemDialog({ }, "Cancel"), /*#__PURE__*/React.createElement("button", { type: "submit", className: "btn-primary", - disabled: !title.trim() - }, mode === "edit" ? "Save changes" : "Create item")))))); + disabled: showPathInput ? !projectPath.trim() : !title.trim() + }, mode === "edit" ? "Save changes" : showPathInput ? "Register project" : "Create item")))))); } // --- Confirm dialog --- @@ -5635,11 +5999,16 @@ function ConfirmDialog({ title, message, detail, + checkbox, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) { + const [checked, setChecked] = useStateD(false); + useEffectD(() => { + if (open) setChecked(false); + }, [open]); return /*#__PURE__*/React.createElement(Dialog, { open: open, onClose: onCancel, @@ -5656,7 +6025,13 @@ function ConfirmDialog({ className: "dlg-msg" }, message), detail && /*#__PURE__*/React.createElement("div", { className: "dlg-detail" - }, detail)), /*#__PURE__*/React.createElement("div", { + }, detail), checkbox && /*#__PURE__*/React.createElement("label", { + className: "dlg-checkbox" + }, /*#__PURE__*/React.createElement("input", { + type: "checkbox", + checked: checked, + onChange: e => setChecked(e.target.checked) + }), /*#__PURE__*/React.createElement("span", null, checkbox))), /*#__PURE__*/React.createElement("div", { className: "dlg-foot" }, /*#__PURE__*/React.createElement("span", null), /*#__PURE__*/React.createElement("div", { className: "dlg-foot-actions" @@ -5667,11 +6042,74 @@ function ConfirmDialog({ }, "Cancel"), /*#__PURE__*/React.createElement("button", { type: "button", className: danger ? "btn-danger" : "btn-primary", - onClick: onConfirm, + onClick: () => onConfirm(checked), autoFocus: true }, confirmLabel)))); } +// --- Prompt dialog (styled input) --- +function PromptDialog({ + open, + title, + message, + placeholder = "", + confirmLabel = "OK", + onCancel, + onConfirm +}) { + const [value, setValue] = useStateD(""); + const inputRef = useRefD(null); + useEffectD(() => { + if (open) { + setValue(""); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + const submit = e => { + e?.preventDefault(); + if (!value.trim()) return; + onConfirm(value.trim()); + }; + return /*#__PURE__*/React.createElement(Dialog, { + open: open, + onClose: onCancel, + width: 480, + labelledBy: "dlg-prompt-title" + }, /*#__PURE__*/React.createElement("form", { + onSubmit: submit + }, /*#__PURE__*/React.createElement(DialogHeader, { + id: "dlg-prompt-title", + eyebrow: "Input", + title: title, + onClose: onCancel + }), /*#__PURE__*/React.createElement("div", { + className: "dlg-body" + }, /*#__PURE__*/React.createElement("p", { + className: "dlg-msg" + }, message), /*#__PURE__*/React.createElement("input", { + ref: inputRef, + className: "text-input", + value: value, + onChange: e => setValue(e.target.value), + placeholder: placeholder, + style: { + marginTop: 12 + } + })), /*#__PURE__*/React.createElement("div", { + className: "dlg-foot" + }, /*#__PURE__*/React.createElement("span", null), /*#__PURE__*/React.createElement("div", { + className: "dlg-foot-actions" + }, /*#__PURE__*/React.createElement("button", { + type: "button", + className: "btn-secondary", + onClick: onCancel + }, "Cancel"), /*#__PURE__*/React.createElement("button", { + type: "submit", + className: "btn-primary", + disabled: !value.trim() + }, confirmLabel))))); +} + // --- Tag autocomplete (typed input + filtered dropdown) --- function TagAutocompleteInput({ tags, @@ -5812,6 +6250,7 @@ function TagMatchHL({ window.Dialog = Dialog; window.ItemDialog = ItemDialog; window.ConfirmDialog = ConfirmDialog; +window.PromptDialog = PromptDialog; window.RichTextEditor = RichTextEditor;