From 7cd6127d5ebdd1a92749c41566d9f02945ffa4ee Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Wed, 29 Jul 2026 17:30:39 -0400 Subject: [PATCH 01/12] T31: add Notes v2 repository and board storage Cites V7, V48, V49, V50, V51, V52, V54. --- SPEC.md | 25 +- scripts/notes.py | 945 +++++++++++++++++++++++++++++----- tests/test_notes_workspace.py | 141 +++++ 3 files changed, 986 insertions(+), 125 deletions(-) create mode 100644 tests/test_notes_workspace.py diff --git a/SPEC.md b/SPEC.md index 378a0d3..cda407f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -17,7 +17,7 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - LLM: FastFlowLM NPU @ `:52625` | Ollama @ `:11434`, OpenAI-compat `POST /v1/chat/completions` - dashboard: daemon-served `scripts/ui/web/{index.html,app.js,styles.css}`, CSP `default-src 'self'` - paths: `scripts/paths.py` → USER_ROOT/{config,data,logs}; `_version.py` = version src of truth -- version: `2.3.0` (prompt-v2 speed+quality release); `2.2.0` released (`v2.2.0` on `25b8794`); repo `agr77one/Fastflow` +- version: `2.4.3`; target `2.5.0` = living Notes workspace + vision board; repo `agr77one/Fastflow` - run tree = `flowkey-pub2` (worktree, branch `live`=origin/main). old `FastFlowPrompt_Local_Setup`=1.5.0 stale. ## §I interfaces @@ -26,6 +26,14 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - api: `POST /action/` ! header `X-FFP-API: 1` → 200 `{ok,result,error,elapsed_ms}` - api: `GET /` → dashboard; `GET /healthz` → `{ok,version,api,actions}` - action: `config_snapshot` → full cfg; `apply_config_patch {patch}` → merge (whitelist `filter_config_patch`) +- action: `notes_query {query?,kind?,status?,category?,tag?,sort?,limit?,offset?}` → `{results,count,facets}` +- action: `note_create {title?,body?,kind?,category?,tags?,color?,due?,source?}` → note +- action: `note_update {note_id,revision,patch}` → note | conflict +- action: `note_trash {note_id}` / `note_restore {note_id}` → note; `note_delete {note_id,permanent:true}` → deleted +- action: `notes_board_get` → `{board,placements}`; `notes_board_save {revision,board}` → board | conflict +- action: `note_stage_capture {text?,source_app?}` / `note_take_staged` → quick-capture payload +- data: note Markdown frontmatter schema v2 → stable `note_id`, `kind`, `status`, `tags`, `color`, `pinned`, `due`, `created`, `updated`, `revision` +- data: `/.flowkey/board.json` → board sections + placements keyed by `note_id` - action: `recent_history {limit?}` → newest history rows; `input_text`/`output_text` iff stored @ write-time - action: `prompt_builder_preview {settings?,sample?}` → deterministic local preview (`⊥` LLM call) - config: `prompt_builder.prompt_version` ∈ {`v1`,`v2`}; default `v2`; v1 = instant rollback @@ -98,6 +106,15 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - V45: surfaced v2 text → article agreement + fixed typo map normalized (∵ render copies user wording verbatim); ⊥ meaning change - V46: A/B rubric = 8 items; R8 = ⊥ section restates `` (coverage-of-task ≥ 0.8), boilerplate-excluded set-wise on constraints; R8 false ⇒ disqualifying ∀ other scores; gate pass ≥ 7/8 ∧ R8 ∧ ⊥ invented - V40: model picker + installed list = app-styled elements (⊥ native ``/` - - -
-

Categories (one per line — the LLM picks from this list)

- -
-
-

LLM behavior

-
- - -
-
- - -
- - - -
-
- - - -
@@ -292,6 +262,30 @@

Hotkeys

Saving applies to the running app within a second (no restart).

+
+

Notes & capture

+

Storage, organization, and optional local-model enrichment for the Notes workspace.

+

Storage

+
+ + +
+

Organization

+ + +

Link extraction & enrichment

+
+ + +
+
+ + +
+ + + +

LLM provider & server

diff --git a/tests/test_notes_config_location.py b/tests/test_notes_config_location.py new file mode 100644 index 0000000..bdf96e3 --- /dev/null +++ b/tests/test_notes_config_location.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "scripts" / "ui" / "web" +NOTE_SETTING_IDS = { + "notes-vault", + "notes-categories", + "notes-fetch-timeout", + "notes-max-chars", + "notes-low-conf", + "notes-gen-title", + "notes-gen-summary", +} + + +def test_v47_note_settings_live_only_in_config_tab(): + html = (WEB / "index.html").read_text(encoding="utf-8") + notes_panel = html.split('id="tab-notes"', 1)[1].split('id="tab-meetings"', 1)[0] + config_panel = html.split('id="tab-config"', 1)[1].split('id="tab-benchmark"', 1)[0] + + for setting_id in NOTE_SETTING_IDS: + assert f'id="{setting_id}"' not in notes_panel + assert f'id="{setting_id}"' in config_panel + assert 'id="config-notes"' in config_panel + assert "Save notes settings" not in html + + +def test_notes_settings_use_config_single_save_flow(): + app = (WEB / "app.js").read_text(encoding="utf-8") + + assert "populateNotesConfig(cfg.notes || {})" in app + assert "notes: notesPatch" in app + assert "function saveNotes" not in app + assert '"notes-save"' not in app From 0f6ab372c060ba5791eeddebfd780359bd1f9af2 Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Wed, 29 Jul 2026 17:52:24 -0400 Subject: [PATCH 04/12] T34: build living Notes workspace and vision board Cites V5, V47, V48, V50, V52, V54, V55, V56. --- SPEC.md | 4 +- scripts/ui/web/app.js | 866 +++++++++++++++++++++++++--- scripts/ui/web/index.html | 171 +++++- scripts/ui/web/styles.css | 439 +++++++++++++- tests/test_notes_config_location.py | 2 +- tests/test_notes_workspace_ui.py | 77 +++ 6 files changed, 1434 insertions(+), 125 deletions(-) create mode 100644 tests/test_notes_workspace_ui.py diff --git a/SPEC.md b/SPEC.md index f39bafb..9383b29 100644 --- a/SPEC.md +++ b/SPEC.md @@ -115,6 +115,7 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - V53: capture hotkey w/ selection → staged prefill; ⊥ selection → blank composer; stale clipboard ⊥ silent capture - V54: note writes + board writes atomic; stale `revision` → conflict, ⊥ overwrite - V55: note card/editor controls keyboard reachable; desktop split workspace + ≤720px stacked layout +- V56: note `due` date-only value renders same calendar day ∀ timezone; ⊥ UTC date shift ## §T tasks @@ -153,7 +154,7 @@ T28|x|model picker 2.4.1: footprint/MoE sizing + never-hide + active-model healt T31|x|Notes schema v2 repository: stable ids, zero-loss migration, CRUD, Trash, indexed query, atomic board store|V7,V48,V49,V50,V51,V52,V54 T32|x|daemon Notes v2 actions + backward-compatible read/move/delete + staged capture|V1,V2,V3,V7,V48,V50,V53,V54 T33|x|move vault/categories/extraction/LLM Notes settings → Config single-save; Notes tab config-free|V3,V47 -T34|.|Notes-only card workspace: composer, editor, smart views, filters, tags, archive/Trash, vision board drag/order|V5,V47,V48,V50,V52,V54,V55 +T34|x|Notes-only card workspace: composer, editor, smart views, filters, tags, archive/Trash, vision board drag/order|V5,V47,V48,V50,V52,V54,V55,V56 T35|.|capture hotkey → Notes quick composer w/ staged selection or blank body|V53 T36|.|2.5.0 docs/version/migration + full release gates|V18,V20,V47,V48,V49,V50,V51,V52,V53,V54,V55 ``` @@ -202,4 +203,5 @@ B35|2026-07-27|`flm bench qwen3.6-moe:35b-a3b` died 4s in w/ driver `0xc01e0200` B36|2026-07-27|keep-warm thread ⊥ aware of benchmarks: warms/reloads active model on 15min tick during a 10-20min bench ∴ NPU+mem contention mid-run|V42; `ffp_benchmark.is_running()` gate in `_warm_model_once` B37|2026-07-27|FLM returns HTTP **200** + `{"error":"Failed to load model!"}`; `_call_openai_compatible`/`ffp_chat` read only `choices` ∴ real cause discarded → "Local LLM returned no usable text"|V43; surface the error body B34|2026-07-27|self-caught: `_active_model_health` tested membership vs `_provider_list("all")`; ollama "all" = installed+suggested (`ffp_provider_runtime:80`) ∴ never-pulled model → false `installed=True` (⊥ warn)|V39; trust `details` (unfiltered, authoritative) else re-list w/ `installed` filter +B41|2026-07-29|Notes due `2026-08-04` parsed as UTC midnight → EDT displayed Aug 3|V56; parse date-only @ local noon ``` diff --git a/scripts/ui/web/app.js b/scripts/ui/web/app.js index ea8a567..e3e681c 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -555,122 +555,766 @@ async function loadHistory() { // ---- Notes ----------------------------------------------------------------- -// Browse the vault: empty query lists the newest notes (notes_list); a query -// runs the ranked search (note_search) and shows snippets instead of dates. -let notesCategories = []; // buckets from config, for the reader's Move dropdown -let currentNoteRelpath = ""; // note open in the reader pane - -// Render the notes table with clickable rows that open the reader. `col3` maps -// a result row to its third-column text (snippet for search, modified for list). -function renderNotesTable(results, col3) { - const body = $("notes-body"); - body.replaceChildren(); - for (const r of results) { - const tr = document.createElement("tr"); - for (const cell of [r.title, r.category, col3(r)]) { - const td = document.createElement("td"); - td.textContent = cell || ""; - tr.append(td); +const NOTE_VIEW_META = { + board: ["Vision board", "Arrange what matters"], + all: ["All notes", "Everything you have captured"], + task: ["Tasks", "Things ready for action"], + idea: ["Ideas", "Possibilities worth growing"], + link: ["Links", "Useful places and references"], + read_later: ["Read later", "A quiet queue for focused reading"], + pinned: ["Pinned", "Keep the important things visible"], + archived: ["Archive", "Notes kept out of the daily flow"], + trashed: ["Trash", "Recover or permanently remove notes"], +}; + +const NOTE_KIND_META = { + note: ["✦", "Note"], + task: ["✓", "Task"], + idea: ["◇", "Idea"], + link: ["↗", "Link"], + read_later: ["◷", "Read later"], +}; + +let notesCategories = []; +let notesSearchTimer = null; +let draggedNoteId = ""; +let notesState = { + view: "board", + query: "", + category: "", + tag: "", + results: [], + facets: { counts: {}, categories: [], tags: [] }, + board: null, + current: null, +}; + +function notesQueryArgs() { + const kind = ["task", "idea", "link", "read_later"].includes(notesState.view) + ? notesState.view + : ""; + let status = ""; + if (notesState.view === "board") status = "active"; + if (notesState.view === "archived") status = "archived"; + if (notesState.view === "trashed") status = "trashed"; + return { + query: notesState.query, + kind, + status, + category: notesState.category, + tag: notesState.tag, + sort: notesState.view === "task" ? "due" : "updated", + limit: 200, + }; +} + +function visibleNotes() { + let notes = [...notesState.results]; + if (!["archived", "trashed", "board"].includes(notesState.view)) { + notes = notes.filter((note) => note.status !== "archived"); + } + if (notesState.view === "pinned") { + notes = notes.filter((note) => note.pinned); + } + return notes; +} + +function setNotesStatus(message, good = true) { + setStatus("notes-status", message || "", good); +} + +function setEditorStatus(message, good = true) { + setStatus("ne-status", message || "", good); +} + +function renderNotesViewButtons() { + document.querySelectorAll("[data-notes-view]").forEach((button) => { + const active = button.dataset.notesView === notesState.view; + button.classList.toggle("active", active); + button.setAttribute("aria-current", active ? "page" : "false"); + }); +} + +function makeFacetButton(label, active, onClick) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `notes-facet-btn${active ? " active" : ""}`; + button.textContent = label; + button.addEventListener("click", onClick); + return button; +} + +function renderNotesFacets() { + const categoryList = $("notes-category-list"); + categoryList.replaceChildren(); + categoryList.append(makeFacetButton( + "All categories", + !notesState.category, + () => { + notesState.category = ""; + loadNotes(false); + }, + )); + for (const category of notesState.facets.categories || []) { + categoryList.append(makeFacetButton( + category, + notesState.category === category, + () => { + notesState.category = notesState.category === category ? "" : category; + loadNotes(false); + }, + )); + } + + const tagList = $("notes-tag-list"); + tagList.replaceChildren(); + for (const tag of (notesState.facets.tags || []).slice(0, 18)) { + tagList.append(makeFacetButton( + `#${tag}`, + notesState.tag === tag, + () => { + notesState.tag = notesState.tag === tag ? "" : tag; + loadNotes(false); + }, + )); + } + if (!(notesState.facets.tags || []).length) { + const empty = document.createElement("span"); + empty.className = "muted small"; + empty.textContent = "Tags appear as you add them."; + tagList.append(empty); + } +} + +function formatNoteDate(value) { + if (!value) return ""; + const raw = String(value); + const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00` : raw); + if (Number.isNaN(date.getTime())) return String(value).replace("T", " ").slice(0, 16); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function noteKindMeta(kind) { + return NOTE_KIND_META[kind] || NOTE_KIND_META.note; +} + +function createNoteCard(note, options = {}) { + const card = document.createElement("article"); + const color = ["yellow", "peach", "pink", "violet", "blue", "mint", "slate"].includes(note.color) + ? note.color + : "yellow"; + card.className = `note-card note-color-${color}`; + card.dataset.noteId = note.note_id; + card.tabIndex = 0; + card.setAttribute("aria-label", `${noteKindMeta(note.kind)[1]}: ${note.title || "Untitled note"}`); + + const top = document.createElement("div"); + top.className = "note-card-top"; + const kind = document.createElement("span"); + kind.className = "note-kind"; + kind.textContent = `${noteKindMeta(note.kind)[0]} ${noteKindMeta(note.kind)[1]}`; + top.append(kind); + if (note.pinned) { + const pin = document.createElement("span"); + pin.className = "note-pin"; + pin.title = "Pinned"; + pin.textContent = "⌖"; + top.append(pin); + } + card.append(top); + + const heading = document.createElement("h3"); + heading.textContent = note.title || "Untitled note"; + card.append(heading); + if (note.excerpt) { + const excerpt = document.createElement("p"); + excerpt.className = "note-card-excerpt"; + excerpt.textContent = note.excerpt; + card.append(excerpt); + } + + const chips = document.createElement("div"); + chips.className = "note-card-chips"; + if (note.kind === "task" && note.status === "done") { + const done = document.createElement("span"); + done.className = "note-chip done"; + done.textContent = "Completed"; + chips.append(done); + } + if (note.due) { + const due = document.createElement("span"); + due.className = "note-chip"; + due.textContent = `Due ${formatNoteDate(note.due)}`; + chips.append(due); + } + if (note.category) { + const category = document.createElement("span"); + category.className = "note-chip"; + category.textContent = note.category; + chips.append(category); + } + for (const tag of (note.tags || []).slice(0, 2)) { + const chip = document.createElement("span"); + chip.className = "note-chip"; + chip.textContent = `#${tag}`; + chips.append(chip); + } + if (chips.childElementCount) card.append(chips); + + const footer = document.createElement("footer"); + const updated = document.createElement("span"); + updated.textContent = formatNoteDate(note.updated || note.created); + footer.append(updated); + if (/^https?:\/\//i.test(note.source || "")) { + const source = document.createElement("a"); + source.href = note.source; + source.target = "_blank"; + source.rel = "noopener"; + source.textContent = "Open source ↗"; + source.addEventListener("click", (event) => event.stopPropagation()); + footer.append(source); + } + if (options.unplaced) { + const add = document.createElement("button"); + add.type = "button"; + add.className = "note-card-action"; + add.textContent = "Add to board"; + add.addEventListener("click", (event) => { + event.stopPropagation(); + moveNoteOnBoard(note.note_id, firstBoardSectionId()); + }); + footer.append(add); + } + card.append(footer); + + const open = () => openNoteEditor(note.note_id); + card.addEventListener("click", open); + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open(); } - if (r.relpath) { - tr.classList.add("note-row"); - tr.tabIndex = 0; - const open = () => openNoteReader(r.relpath); - tr.addEventListener("click", open); - tr.addEventListener("keydown", (e) => { if (e.key === "Enter") open(); }); + }); + if (options.draggable) { + card.draggable = true; + card.addEventListener("dragstart", (event) => { + draggedNoteId = note.note_id; + card.classList.add("dragging"); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", note.note_id); + } + }); + card.addEventListener("dragend", () => { + draggedNoteId = ""; + card.classList.remove("dragging"); + }); + } + return card; +} + +function renderNoteGrid(notes) { + const grid = $("notes-card-grid"); + grid.replaceChildren(); + for (const note of notes) grid.append(createNoteCard(note)); +} + +function firstBoardSectionId() { + return notesState.board?.sections?.[0]?.id || "now"; +} + +function populateBoardSectionSelect(selected = "") { + const select = $("ne-board-section"); + select.replaceChildren(); + for (const section of notesState.board?.sections || []) { + const option = document.createElement("option"); + option.value = section.id; + option.textContent = section.title; + option.selected = section.id === selected; + select.append(option); + } + if (!select.value && select.options.length) select.value = select.options[0].value; +} + +function placementFor(noteId) { + return (notesState.board?.placements || []).find((item) => item.note_id === noteId) || null; +} + +function normalizeBoardOrders() { + const bySection = new Map(); + for (const placement of notesState.board?.placements || []) { + if (!bySection.has(placement.section_id)) bySection.set(placement.section_id, []); + bySection.get(placement.section_id).push(placement); + } + for (const placements of bySection.values()) { + placements.sort((a, b) => Number(a.order || 0) - Number(b.order || 0)); + placements.forEach((placement, index) => { placement.order = index; }); + } +} + +async function saveNotesBoard() { + if (!notesState.board) return false; + normalizeBoardOrders(); + try { + const result = await action("notes_board_save", { + revision: notesState.board.revision, + board: notesState.board, + }); + if (!result.ok) { + if (result.board) notesState.board = result.board; + setNotesStatus(result.error || "Board could not be saved.", false); + return false; } - body.append(tr); + notesState.board = result.board; + return true; + } catch (error) { + setNotesStatus(`Board save failed: ${error.message}`, false); + return false; + } +} + +async function moveNoteOnBoard(noteId, sectionId) { + if (!notesState.board) return; + const placements = notesState.board.placements || []; + notesState.board.placements = placements.filter((item) => item.note_id !== noteId); + const order = notesState.board.placements.filter((item) => item.section_id === sectionId).length; + notesState.board.placements.push({ + note_id: noteId, + section_id: sectionId || firstBoardSectionId(), + order, + size: "medium", + }); + if (await saveNotesBoard()) renderNotesWorkspace(); +} + +async function removeNoteFromBoard(noteId) { + if (!notesState.board) return; + const before = notesState.board.placements || []; + notesState.board.placements = before.filter((item) => item.note_id !== noteId); + if (before.length !== notesState.board.placements.length) await saveNotesBoard(); +} + +async function renameBoardSection(sectionId, title) { + const section = (notesState.board?.sections || []).find((item) => item.id === sectionId); + if (!section) return; + section.title = String(title || "Section").trim().slice(0, 60) || "Section"; + await saveNotesBoard(); +} + +async function removeBoardSection(sectionId) { + const sections = notesState.board?.sections || []; + if (sections.length <= 1) { + setNotesStatus("The board needs at least one section.", false); + return; + } + const section = sections.find((item) => item.id === sectionId); + if (!(await confirmDialog(`Remove the '${section?.title || "section"}' section? Its notes will move to the first section.`, "Remove section"))) { + return; + } + notesState.board.sections = sections.filter((item) => item.id !== sectionId); + const fallback = firstBoardSectionId(); + for (const placement of notesState.board.placements || []) { + if (placement.section_id === sectionId) placement.section_id = fallback; + } + if (await saveNotesBoard()) renderNotesWorkspace(); +} + +function renderNotesBoard(notes) { + const board = $("notes-board"); + board.replaceChildren(); + const noteMap = new Map(notes.map((note) => [note.note_id, note])); + const placedIds = new Set(); + for (const section of notesState.board?.sections || []) { + const column = document.createElement("section"); + column.className = "notes-board-column"; + column.dataset.sectionId = section.id; + + const header = document.createElement("div"); + header.className = "notes-board-column-head"; + const name = document.createElement("input"); + name.type = "text"; + name.value = section.title; + name.maxLength = 60; + name.setAttribute("aria-label", "Board section name"); + name.addEventListener("change", () => renameBoardSection(section.id, name.value)); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "btn btn-icon"; + remove.title = "Remove section"; + remove.setAttribute("aria-label", `Remove ${section.title} section`); + remove.textContent = "×"; + remove.addEventListener("click", () => removeBoardSection(section.id)); + header.append(name, remove); + column.append(header); + + const dropzone = document.createElement("div"); + dropzone.className = "notes-board-dropzone"; + dropzone.dataset.sectionId = section.id; + dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + dropzone.classList.add("drag-over"); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + }); + dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag-over")); + dropzone.addEventListener("drop", (event) => { + event.preventDefault(); + dropzone.classList.remove("drag-over"); + const noteId = event.dataTransfer?.getData("text/plain") || draggedNoteId; + if (noteId) moveNoteOnBoard(noteId, section.id); + }); + + const placements = (notesState.board?.placements || []) + .filter((item) => item.section_id === section.id) + .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)); + for (const placement of placements) { + const note = noteMap.get(placement.note_id); + if (!note) continue; + placedIds.add(note.note_id); + dropzone.append(createNoteCard(note, { draggable: true })); + } + if (!dropzone.childElementCount) { + const hint = document.createElement("p"); + hint.className = "notes-drop-hint"; + hint.textContent = "Drag a note here"; + dropzone.append(hint); + } + column.append(dropzone); + board.append(column); + } + + const unplaced = notes.filter((note) => !placedIds.has(note.note_id)); + const unplacedGrid = $("notes-unplaced-grid"); + unplacedGrid.replaceChildren(); + for (const note of unplaced) unplacedGrid.append(createNoteCard(note, { unplaced: true })); + $("notes-unplaced").hidden = unplaced.length === 0; +} + +function renderNotesWorkspace() { + const meta = NOTE_VIEW_META[notesState.view] || NOTE_VIEW_META.all; + $("notes-view-title").textContent = meta[0]; + $("notes-view-kicker").textContent = meta[1]; + const notes = visibleNotes(); + $("notes-count").textContent = `(${notes.length})`; + renderNotesViewButtons(); + renderNotesFacets(); + const boardMode = notesState.view === "board"; + $("notes-board-view").hidden = !boardMode; + $("notes-card-grid").hidden = boardMode; + $("board-add-section").hidden = !boardMode; + if (boardMode) renderNotesBoard(notes); + else renderNoteGrid(notes); + const isEmpty = notes.length === 0; + $("notes-empty").hidden = !isEmpty; + if (isEmpty) { + $("notes-board-view").hidden = true; + $("notes-card-grid").hidden = true; + } + populateBoardSectionSelect(placementFor(notesState.current?.note_id)?.section_id || ""); +} + +function setNotesView(view) { + if (!NOTE_VIEW_META[view]) return; + notesState.view = view; + loadNotes(false); +} + +function setEditorOpen(open) { + $("note-editor").hidden = !open; + $("notes-layout").classList.toggle("editor-open", open); +} + +function populateEditorCategories(selected = INBOX_PLACEHOLDER) { + const categories = [ + "inbox", + ...notesCategories, + ...(notesState.facets.categories || []), + ].filter((value, index, all) => value && all.indexOf(value) === index); + const select = $("ne-category"); + select.replaceChildren(); + for (const category of categories) { + const option = document.createElement("option"); + option.value = category; + option.textContent = category; + option.selected = category === selected; + select.append(option); + } +} + +const INBOX_PLACEHOLDER = "inbox"; + +function setEditorReadOnly(trashed) { + for (const id of [ + "ne-title", "ne-kind", "ne-status-select", "ne-body", "ne-category", + "ne-color", "ne-tags", "ne-due", "ne-source", "ne-pinned", + "ne-on-board", "ne-board-section", + ]) { + $(id).disabled = trashed; + } + $("ne-save").hidden = trashed; + $("ne-archive").hidden = trashed || notesState.current?.status === "archived"; + $("ne-trash").hidden = trashed; + $("ne-restore").hidden = !trashed; + $("ne-delete").hidden = !trashed; +} + +function fillNoteEditor(note) { + const isNew = !note; + const current = note || { + title: "", + body: "", + kind: "note", + status: "active", + category: "inbox", + tags: [], + color: "yellow", + due: "", + source: "", + pinned: false, + summary: "", + }; + $("ne-kicker").textContent = isNew ? "Quick capture" : noteKindMeta(current.kind)[1]; + $("ne-heading").textContent = isNew ? "New note" : "Edit note"; + $("ne-title").value = current.title || ""; + $("ne-body").value = current.body || ""; + $("ne-kind").value = current.kind || "note"; + $("ne-status-select").value = current.status === "trashed" ? "active" : (current.status || "active"); + populateEditorCategories(current.category || "inbox"); + $("ne-color").value = current.color || "yellow"; + $("ne-tags").value = (current.tags || []).join(", "); + $("ne-due").value = (current.due || "").slice(0, 10); + $("ne-source").value = current.source || ""; + $("ne-pinned").checked = !!current.pinned; + const placement = current.note_id ? placementFor(current.note_id) : null; + $("ne-on-board").checked = !!placement || (isNew && notesState.view === "board"); + populateBoardSectionSelect(placement?.section_id || firstBoardSectionId()); + $("ne-board-row").hidden = !$("ne-on-board").checked; + $("ne-summary").hidden = !current.summary; + $("ne-summary").textContent = current.summary ? `Local AI summary · ${current.summary}` : ""; + $("ne-save").textContent = isNew ? "Create note" : "Save note"; + setEditorReadOnly(current.status === "trashed"); + setEditorStatus(""); +} + +function openNewNote(prefill = "") { + notesState.current = null; + fillNoteEditor(null); + const captured = String(prefill || ""); + $("ne-body").value = captured; + if (/^https?:\/\/\S+$/i.test(captured.trim())) { + $("ne-kind").value = "read_later"; + $("ne-source").value = captured.trim(); + } + setEditorOpen(true); + $("ne-body").focus(); +} + +async function openNoteEditor(noteId) { + try { + const note = await action("note_get", { note_id: noteId }); + if (!note.ok) { + setNotesStatus(note.error || "Note not found.", false); + return; + } + notesState.current = note; + fillNoteEditor(note); + setEditorOpen(true); + } catch (error) { + setNotesStatus(`Open failed: ${error.message}`, false); } - return results.length; } -async function browseNotes() { - const query = $("note-query").value.trim(); +function closeNoteEditor() { + notesState.current = null; + setEditorOpen(false); +} + +function editorNoteFields() { + return { + title: $("ne-title").value.trim(), + body: $("ne-body").value, + kind: $("ne-kind").value, + status: $("ne-status-select").value, + category: $("ne-category").value || "inbox", + tags: $("ne-tags").value, + color: $("ne-color").value, + due: $("ne-due").value, + source: $("ne-source").value.trim(), + pinned: $("ne-pinned").checked, + }; +} + +async function syncEditorBoardPlacement(noteId) { + if (!notesState.board) return; + if ($("ne-on-board").checked) { + const sectionId = $("ne-board-section").value || firstBoardSectionId(); + const existing = placementFor(noteId); + if (existing) existing.section_id = sectionId; + else { + notesState.board.placements.push({ + note_id: noteId, + section_id: sectionId, + order: notesState.board.placements.filter((item) => item.section_id === sectionId).length, + size: "medium", + }); + } + await saveNotesBoard(); + } else { + await removeNoteFromBoard(noteId); + } +} + +async function saveNoteEditor() { + const fields = editorNoteFields(); + if (!fields.title && !fields.body.trim() && !fields.source) { + setEditorStatus("Write something before saving.", false); + return; + } + setEditorStatus("Saving…"); try { - if (query) { - const res = await action("note_search", { query, limit: 20 }); - $("notes-col3").textContent = "Snippet"; - const n = renderNotesTable(res.results || [], (r) => r.snippet || ""); - $("notes-count").textContent = `(${res.count} match${res.count === 1 ? "" : "es"})`; - $("notes-empty").hidden = n > 0; + let saved; + if (notesState.current?.note_id) { + saved = await action("note_update", { + note_id: notesState.current.note_id, + revision: notesState.current.revision, + patch: fields, + }); } else { - const res = await action("notes_list", { limit: 20 }); - $("notes-col3").textContent = "Modified"; - const n = renderNotesTable(res.results || [], (r) => r.modified); - $("notes-count").textContent = `(${res.count} total — newest 20)`; - $("notes-empty").hidden = n > 0; + saved = await action("note_create", { ...fields, captured_via: "dashboard" }); } - } catch (e) { - fillTable("notes-body", [[`Notes unavailable: ${e.message}`, "", ""]]); - $("notes-empty").hidden = true; + if (!saved.ok) { + if (saved.conflict && saved.note) { + notesState.current = saved.note; + fillNoteEditor(saved.note); + } + setEditorStatus(saved.error || "Save failed.", false); + return; + } + await syncEditorBoardPlacement(saved.note_id); + notesState.current = saved; + await loadNotes(false); + fillNoteEditor(saved); + setEditorStatus("Saved."); + } catch (error) { + setEditorStatus(`Save failed: ${error.message}`, false); } } -async function openNoteReader(relpath) { +async function archiveCurrentNote() { + if (!notesState.current?.note_id) return; try { - const n = await action("note_get", { relpath }); - if (!n.ok) { setStatus("nr-status", n.error || "note not found", false); return; } - currentNoteRelpath = n.relpath || relpath; - $("nr-title").textContent = n.title || "(untitled)"; - $("nr-body").textContent = n.body || ""; - const src = $("nr-source"); - if (n.source) { src.textContent = n.source; src.href = n.source; src.hidden = false; } - else { src.hidden = true; src.removeAttribute("href"); } - // Bucket dropdown: configured categories + inbox, plus the note's current - // category if it isn't in the list. - const cats = [...notesCategories]; - if (!cats.includes("inbox")) cats.push("inbox"); - if (n.category && !cats.includes(n.category)) cats.unshift(n.category); - const sel = $("nr-bucket"); - sel.replaceChildren(); - for (const c of cats) { - const o = document.createElement("option"); - o.value = c; o.textContent = c; - if (c === n.category) o.selected = true; - sel.append(o); + const result = await action("note_archive", { + note_id: notesState.current.note_id, + revision: notesState.current.revision, + }); + if (!result.ok) { + setEditorStatus(result.error || "Archive failed.", false); + return; } - setStatus("nr-status", ""); - $("note-reader").hidden = false; - $("note-reader").scrollIntoView({ behavior: "smooth", block: "nearest" }); - } catch (e) { - setStatus("nr-status", `Open failed: ${e.message}`, false); + await removeNoteFromBoard(result.note_id); + closeNoteEditor(); + await loadNotes(false); + } catch (error) { + setEditorStatus(`Archive failed: ${error.message}`, false); } } -async function moveNoteToBucket() { - if (!currentNoteRelpath) return; - const category = $("nr-bucket").value; +async function trashCurrentNote() { + if (!notesState.current?.note_id) { + closeNoteEditor(); + return; + } + if (!(await confirmDialog("Move this note to Trash? You can restore it later.", "Move to Trash"))) return; try { - const res = await action("note_move", { relpath: currentNoteRelpath, category }); - if (!res.ok) { setStatus("nr-status", res.error || "move failed", false); return; } - currentNoteRelpath = res.relpath || currentNoteRelpath; - setStatus("nr-status", `Moved to ${res.category}.`); - browseNotes(); - } catch (e) { - setStatus("nr-status", `Move failed: ${e.message}`, false); + const noteId = notesState.current.note_id; + const result = await action("note_trash", { note_id: noteId }); + if (!result.ok) { + setEditorStatus(result.error || "Move to Trash failed.", false); + return; + } + await removeNoteFromBoard(noteId); + closeNoteEditor(); + await loadNotes(false); + } catch (error) { + setEditorStatus(`Move to Trash failed: ${error.message}`, false); } } -async function deleteCurrentNote() { - if (!currentNoteRelpath) return; - if (!(await confirmDialog("Delete this note from the vault?", "Delete"))) return; +async function restoreCurrentNote() { + if (!notesState.current?.note_id) return; try { - const res = await action("note_delete", { relpath: currentNoteRelpath }); - if (!res.ok) { setStatus("nr-status", res.error || "delete failed", false); return; } - $("note-reader").hidden = true; - currentNoteRelpath = ""; - browseNotes(); - } catch (e) { - setStatus("nr-status", `Delete failed: ${e.message}`, false); + const result = await action("note_restore", { note_id: notesState.current.note_id }); + if (!result.ok) { + setEditorStatus(result.error || "Restore failed.", false); + return; + } + notesState.current = result; + await loadNotes(false); + fillNoteEditor(result); + setEditorStatus("Restored."); + } catch (error) { + setEditorStatus(`Restore failed: ${error.message}`, false); } } -async function loadNotes() { - browseNotes(); +async function permanentlyDeleteCurrentNote() { + if (!notesState.current?.note_id) return; + if (!(await confirmDialog("Permanently delete this note? This cannot be undone.", "Delete forever"))) return; try { - const cfg = await action("config_snapshot"); + const result = await action("note_delete", { + note_id: notesState.current.note_id, + permanent: true, + }); + if (!result.ok) { + setEditorStatus(result.error || "Delete failed.", false); + return; + } + closeNoteEditor(); + await loadNotes(false); + } catch (error) { + setEditorStatus(`Delete failed: ${error.message}`, false); + } +} + +async function addBoardSection() { + const input = $("board-section-title"); + const title = input.value.trim(); + if (!title || !notesState.board) return; + const base = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "section"; + const existing = new Set(notesState.board.sections.map((section) => section.id)); + let sectionId = base; + let suffix = 2; + while (existing.has(sectionId)) { + sectionId = `${base}-${suffix}`; + suffix += 1; + } + notesState.board.sections.push({ id: sectionId, title: title.slice(0, 60) }); + input.value = ""; + if (await saveNotesBoard()) renderNotesWorkspace(); +} + +async function loadNotes(takeStaged = true) { + setNotesStatus("Loading…"); + try { + const [feed, boardResult, cfg] = await Promise.all([ + action("notes_query", notesQueryArgs()), + action("notes_board_get"), + action("config_snapshot"), + ]); + notesState.results = feed.results || []; + notesState.facets = feed.facets || { counts: {}, categories: [], tags: [] }; + notesState.board = boardResult.board; notesCategories = (cfg.notes || {}).categories || []; - } catch (_e) {} + renderNotesWorkspace(); + setNotesStatus(""); + if (takeStaged) { + const staged = await action("note_take_staged"); + if (staged.staged) openNewNote(staged.text || ""); + } + } catch (error) { + setNotesStatus(`Notes unavailable: ${error.message}`, false); + notesState.results = []; + renderNotesWorkspace(); + } } function populateNotesConfig(notes) { @@ -2027,13 +2671,43 @@ document.addEventListener("DOMContentLoaded", () => { $("refresh-btn").addEventListener("click", refreshAll); $("theme-btn").addEventListener("click", cycleTheme); applyTheme(localStorage.getItem(THEME_KEY) || "auto"); - $("note-search-btn").addEventListener("click", browseNotes); - $("note-query").addEventListener("keydown", (e) => { - if (e.key === "Enter") browseNotes(); + $("note-query").addEventListener("input", (event) => { + notesState.query = event.target.value.trim(); + clearTimeout(notesSearchTimer); + notesSearchTimer = setTimeout(() => loadNotes(false), 180); + }); + $("note-query").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + clearTimeout(notesSearchTimer); + notesState.query = event.target.value.trim(); + loadNotes(false); + } + }); + $("notes-view-list").addEventListener("click", (event) => { + const button = event.target.closest("[data-notes-view]"); + if (button) setNotesView(button.dataset.notesView); + }); + $("note-new").addEventListener("click", () => openNewNote()); + $("notes-empty-new").addEventListener("click", () => openNewNote()); + $("ne-close").addEventListener("click", closeNoteEditor); + $("ne-save").addEventListener("click", saveNoteEditor); + $("ne-archive").addEventListener("click", archiveCurrentNote); + $("ne-trash").addEventListener("click", trashCurrentNote); + $("ne-restore").addEventListener("click", restoreCurrentNote); + $("ne-delete").addEventListener("click", permanentlyDeleteCurrentNote); + $("ne-on-board").addEventListener("change", () => { + $("ne-board-row").hidden = !$("ne-on-board").checked; + }); + $("ne-body").addEventListener("keydown", (event) => { + if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + saveNoteEditor(); + } + }); + $("board-section-add").addEventListener("click", addBoardSection); + $("board-section-title").addEventListener("keydown", (event) => { + if (event.key === "Enter") addBoardSection(); }); - $("nr-move").addEventListener("click", moveNoteToBucket); - $("nr-delete").addEventListener("click", deleteCurrentNote); - $("nr-close").addEventListener("click", () => { $("note-reader").hidden = true; }); $("history-view-telemetry").addEventListener("click", () => setHistoryView("telemetry")); $("history-view-exposed").addEventListener("click", () => setHistoryView("exposed")); $("history-storage-action").addEventListener("click", setHistoryStorageFromBanner); diff --git a/scripts/ui/web/index.html b/scripts/ui/web/index.html index 0ad399e..8e15fff 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -151,34 +151,153 @@

Recent activity (last 50, newest first)

-
-
-

Your notes

-
- - -
- - - -
TitleCategoryModified
- -

Click a note to read it, move it to another bucket, or delete it.

-
-
diff --git a/scripts/ui/web/styles.css b/scripts/ui/web/styles.css index 93c30e1..d3987f4 100644 --- a/scripts/ui/web/styles.css +++ b/scripts/ui/web/styles.css @@ -86,6 +86,7 @@ } * { box-sizing: border-box; } +[hidden] { display: none !important; } html { scrollbar-gutter: stable; } @@ -467,7 +468,8 @@ kbd { .history-notice { margin: 6px 0 10px; } /* ---- Forms (Notes, Config) ---- */ -input[type="text"], input[type="number"], textarea, select { +input[type="text"], input[type="search"], input[type="url"], input[type="date"], +input[type="time"], input[type="number"], textarea, select { background: var(--bg); color: var(--text); border: 1px solid var(--border); @@ -812,6 +814,441 @@ footer { padding: 14px 24px 22px; } border-radius: 8px; padding: 12px; } +/* ---- Notes 2.5 workspace ---- */ +.notes-shell { display: flex; flex-direction: column; gap: 14px; } +.notes-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 15px 18px; + overflow: visible; +} +.notes-toolbar:hover, .notes-sidebar:hover, .notes-editor:hover { + transform: none; + box-shadow: var(--shadow); +} +.notes-toolbar h2, +.notes-view-head h2, +.notes-editor h2 { + margin: 0; + color: var(--text); + font-size: 21px; + font-weight: 750; + letter-spacing: -0.025em; + text-transform: none; +} +.eyebrow { + margin: 0 0 2px; + color: var(--accent); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.notes-toolbar-actions { + display: flex; + align-items: center; + gap: 9px; + flex: 1; + justify-content: flex-end; +} +.notes-toolbar-actions input { max-width: 520px; } +.notes-toolbar-actions .btn { white-space: nowrap; } +.btn-icon { + width: 34px; + min-width: 34px; + padding: 5px; + display: inline-grid; + place-items: center; +} + +.notes-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 14px; + align-items: start; +} +.notes-layout.editor-open { + grid-template-columns: 190px minmax(0, 1fr) minmax(310px, 360px); +} +.notes-sidebar { + position: sticky; + top: 132px; + padding: 10px; + max-height: calc(100vh - 150px); + overflow-y: auto; +} +.notes-view-list { display: flex; flex-direction: column; gap: 3px; } +.notes-view-btn { + width: 100%; + display: flex; + align-items: center; + gap: 9px; + border: 0; + border-radius: 9px; + padding: 8px 10px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.notes-view-btn span { + width: 18px; + color: var(--accent); + text-align: center; +} +.notes-view-btn:hover { background: var(--accent-soft); color: var(--text); } +.notes-view-btn.active { + color: var(--text); + background: color-mix(in srgb, var(--accent) 13%, transparent); + box-shadow: inset 3px 0 0 var(--accent); +} +.notes-view-btn:focus-visible, +.notes-facet-btn:focus-visible, +.note-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.notes-facet-block { + margin-top: 16px; + padding: 0 4px; +} +.notes-facet-block h3 { + margin: 0 0 6px; + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.notes-facet-list { display: flex; flex-direction: column; gap: 2px; } +.notes-tag-list { display: flex; flex-wrap: wrap; gap: 5px; } +.notes-facet-btn { + border: 0; + border-radius: 7px; + padding: 4px 7px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.notes-facet-btn:hover, +.notes-facet-btn.active { + color: var(--accent); + background: var(--accent-soft); +} + +.notes-content { min-width: 0; } +.notes-view-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + margin: 2px 2px 12px; +} +.notes-view-head.compact { margin-top: 20px; } +.notes-view-head h3 { margin: 0; font-size: 16px; } +.board-add-section { + display: flex; + gap: 6px; + align-items: center; +} +.board-add-section input { width: 150px; } + +.notes-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + align-items: stretch; +} +.note-card { + --note-bg: color-mix(in srgb, #facc15 17%, var(--surface)); + --note-edge: color-mix(in srgb, #ca8a04 42%, var(--border)); + min-height: 190px; + display: flex; + flex-direction: column; + padding: 14px; + border: 1px solid var(--note-edge); + border-radius: 13px 13px 13px 5px; + background: var(--note-bg); + box-shadow: 0 3px 10px color-mix(in srgb, var(--note-edge) 18%, transparent); + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; + overflow: hidden; +} +.note-card:hover { + transform: translateY(-3px) rotate(-0.25deg); + box-shadow: 0 9px 22px color-mix(in srgb, var(--note-edge) 25%, transparent); +} +.note-card.dragging { opacity: 0.48; transform: rotate(2deg); } +.note-color-peach { + --note-bg: color-mix(in srgb, #fb923c 17%, var(--surface)); + --note-edge: color-mix(in srgb, #ea580c 38%, var(--border)); +} +.note-color-pink { + --note-bg: color-mix(in srgb, #f472b6 16%, var(--surface)); + --note-edge: color-mix(in srgb, #db2777 36%, var(--border)); +} +.note-color-violet { + --note-bg: color-mix(in srgb, #a78bfa 17%, var(--surface)); + --note-edge: color-mix(in srgb, #7c3aed 36%, var(--border)); +} +.note-color-blue { + --note-bg: color-mix(in srgb, #60a5fa 16%, var(--surface)); + --note-edge: color-mix(in srgb, #2563eb 36%, var(--border)); +} +.note-color-mint { + --note-bg: color-mix(in srgb, #34d399 15%, var(--surface)); + --note-edge: color-mix(in srgb, #059669 35%, var(--border)); +} +.note-color-slate { + --note-bg: color-mix(in srgb, #94a3b8 16%, var(--surface)); + --note-edge: color-mix(in srgb, #64748b 38%, var(--border)); +} +.note-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.note-kind { + color: color-mix(in srgb, var(--text) 72%, var(--accent)); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.note-pin { color: var(--accent); font-size: 17px; } +.note-card h3 { + margin: 12px 0 6px; + font-size: 16px; + line-height: 1.25; + letter-spacing: -0.01em; +} +.note-card-excerpt { + display: -webkit-box; + margin: 0 0 10px; + color: color-mix(in srgb, var(--text) 82%, transparent); + line-height: 1.43; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; +} +.note-card-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: auto; +} +.note-chip { + max-width: 100%; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--note-edge) 55%, transparent); + border-radius: 999px; + color: var(--text-muted); + background: color-mix(in srgb, var(--surface) 56%, transparent); + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.note-chip.done { color: var(--ok); } +.note-card footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--note-edge) 42%, transparent); + color: var(--text-muted); + font-size: 10px; +} +.note-card footer a { margin-left: auto; color: var(--accent); } +.note-card-action { + margin-left: auto; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--accent); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.notes-board { + display: flex; + gap: 12px; + padding: 2px 2px 12px; + overflow-x: auto; + scroll-snap-type: x proximity; +} +.notes-board-column { + flex: 1 0 270px; + min-width: 270px; + max-width: 380px; + padding: 9px; + border: 1px solid var(--border); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 74%, transparent); + scroll-snap-align: start; +} +.notes-board-column-head { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 8px; +} +.notes-board-column-head input { + border-color: transparent; + background: transparent; + padding: 4px 6px; + font-weight: 750; +} +.notes-board-column-head input:focus { background: var(--surface); border-color: var(--accent); } +.notes-board-dropzone { + min-height: 180px; + display: flex; + flex-direction: column; + gap: 9px; + border-radius: 11px; + transition: background 0.15s, box-shadow 0.15s; +} +.notes-board-dropzone .note-card { min-height: 170px; } +.notes-board-dropzone.drag-over { + background: color-mix(in srgb, var(--accent) 9%, transparent); + box-shadow: inset 0 0 0 2px var(--accent); +} +.notes-drop-hint { + display: grid; + min-height: 165px; + place-items: center; + margin: 0; + border: 1px dashed var(--border-strong); + border-radius: 10px; + color: var(--text-muted); + font-size: 12px; +} +.notes-unplaced { + padding-top: 4px; + border-top: 1px solid var(--border); +} + +.notes-editor { + position: sticky; + top: 132px; + display: flex; + flex-direction: column; + gap: 6px; + max-height: calc(100vh - 150px); + padding: 16px; + overflow-y: auto; +} +.notes-editor-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; +} +.notes-editor label:not(.check-row) { + margin-top: 5px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.notes-editor textarea { + min-height: 220px; + resize: vertical; + background: color-mix(in srgb, var(--bg) 65%, var(--surface)); + font-family: "Segoe UI", system-ui, sans-serif; + line-height: 1.5; +} +.notes-editor-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.notes-editor-pair > div { min-width: 0; } +.notes-editor-pair label { display: block; margin-bottom: 4px; } +.notes-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.notes-ai-summary { + margin: 7px 0 0; + padding: 9px 10px; + border-left: 3px solid var(--accent); + border-radius: 6px; + background: var(--accent-soft); + color: var(--text-muted); + font-size: 12px; + white-space: pre-wrap; +} +.notes-empty { + display: grid; + place-items: center; + padding: 44px 20px; + text-align: center; +} +.notes-empty h3 { margin: 8px 0 0; } +.notes-empty p { margin: 4px 0 16px; } +.notes-empty-icon { + display: grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: 16px; + background: var(--grad); + color: #fff; + font-size: 24px; + box-shadow: 0 8px 22px var(--ring); +} + +@media (max-width: 1120px) { + .notes-layout, + .notes-layout.editor-open { grid-template-columns: 180px minmax(0, 1fr); } + .notes-editor { + position: relative; + top: auto; + grid-column: 1 / -1; + max-height: none; + } +} + +@media (max-width: 720px) { + main { padding-inline: 12px; } + .notes-toolbar { align-items: stretch; flex-direction: column; } + .notes-toolbar-actions { align-items: stretch; flex-direction: column; } + .notes-toolbar-actions input { max-width: none; } + .notes-layout, + .notes-layout.editor-open { display: block; } + .notes-sidebar { + position: relative; + top: auto; + max-height: none; + margin-bottom: 12px; + } + .notes-view-list { + flex-direction: row; + overflow-x: auto; + padding-bottom: 3px; + } + .notes-view-btn { width: auto; flex: 0 0 auto; } + .notes-facet-block { display: none; } + .notes-view-head { align-items: stretch; flex-direction: column; } + .board-add-section input { flex: 1; width: auto; } + .notes-card-grid { grid-template-columns: 1fr; } + .notes-board-column { flex-basis: min(82vw, 320px); min-width: min(82vw, 320px); } + .notes-editor { margin-top: 12px; } +} + /* ---- In-page confirm modal (replaces native confirm/alert) ---- */ .modal-overlay { position: fixed; inset: 0; z-index: 50; diff --git a/tests/test_notes_config_location.py b/tests/test_notes_config_location.py index bdf96e3..b12346c 100644 --- a/tests/test_notes_config_location.py +++ b/tests/test_notes_config_location.py @@ -32,5 +32,5 @@ def test_notes_settings_use_config_single_save_flow(): assert "populateNotesConfig(cfg.notes || {})" in app assert "notes: notesPatch" in app - assert "function saveNotes" not in app + assert "function saveNotes()" not in app assert '"notes-save"' not in app diff --git a/tests/test_notes_workspace_ui.py b/tests/test_notes_workspace_ui.py new file mode 100644 index 0000000..fd4ab7d --- /dev/null +++ b/tests/test_notes_workspace_ui.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "scripts" / "ui" / "web" + + +def test_notes_panel_is_a_workspace_not_a_table_or_settings_page(): + html = (WEB / "index.html").read_text(encoding="utf-8") + notes_panel = html.split('id="tab-notes"', 1)[1].split('id="tab-meetings"', 1)[0] + + for required_id in ( + "notes-layout", + "notes-view-list", + "notes-board", + "notes-card-grid", + "note-editor", + "note-new", + "ne-title", + "ne-body", + "ne-kind", + "ne-category", + "ne-tags", + "ne-color", + "ne-on-board", + "ne-save", + "ne-trash", + "ne-restore", + "ne-delete", + ): + assert f'id="{required_id}"' in notes_panel + for removed_id in ("notes-body", "notes-col3", "note-reader", "notes-vault"): + assert f'id="{removed_id}"' not in notes_panel + assert " Date: Wed, 29 Jul 2026 17:55:27 -0400 Subject: [PATCH 05/12] T35: open Notes composer from capture hotkey Cites V53. --- SPEC.md | 2 +- scripts/grammarFix.ahk | 42 +++++++++---------------- scripts/lib/clipboard.ahk | 33 ++++++++++++++++++++ tests/test_ffp_notifications.py | 6 ++-- tests/test_notes_hotkey.py | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 tests/test_notes_hotkey.py diff --git a/SPEC.md b/SPEC.md index 9383b29..1e1e5cc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -155,7 +155,7 @@ T31|x|Notes schema v2 repository: stable ids, zero-loss migration, CRUD, Trash, T32|x|daemon Notes v2 actions + backward-compatible read/move/delete + staged capture|V1,V2,V3,V7,V48,V50,V53,V54 T33|x|move vault/categories/extraction/LLM Notes settings → Config single-save; Notes tab config-free|V3,V47 T34|x|Notes-only card workspace: composer, editor, smart views, filters, tags, archive/Trash, vision board drag/order|V5,V47,V48,V50,V52,V54,V55,V56 -T35|.|capture hotkey → Notes quick composer w/ staged selection or blank body|V53 +T35|x|capture hotkey → Notes quick composer w/ staged selection or blank body|V53 T36|.|2.5.0 docs/version/migration + full release gates|V18,V20,V47,V48,V49,V50,V51,V52,V53,V54,V55 ``` diff --git a/scripts/grammarFix.ahk b/scripts/grammarFix.ahk index a3ef994..6f6a6ee 100644 --- a/scripts/grammarFix.ahk +++ b/scripts/grammarFix.ahk @@ -449,19 +449,9 @@ ShutdownFlowkeyChildren(ExitReason := "", ExitCode := "") { ; ---------------------------------------------------------------------------- ; Note capture (Ctrl+Alt+N). ; -; Capture strategy (in order): -; 1. Save the existing clipboard contents (so we can restore them and use -; them as a fallback). -; 2. Try Send("^c") to copy whatever's currently selected. Some apps eat -; this synthetic Ctrl+C (web inputs, PDF viewers, Citrix sessions); -; that's an acceptable failure mode. -; 3. If the fresh copy produced text → use it. Otherwise fall back to the -; clipboard contents from step 1 (lets the user copy manually first, -; then press Ctrl+Alt+N). -; 4. If both are empty → toast and bail. -; -; Daemon writes an inbox stub instantly; LLM categorization happens in a -; background thread and posts a follow-up toast with the final category. +; A fresh selection is staged for the Notes quick composer. With no selection, +; the composer still opens blank. The user's previous clipboard is never used +; as note content, so pressing the hotkey cannot save stale text by accident. ; ---------------------------------------------------------------------------- CaptureNote() { @@ -477,31 +467,29 @@ CaptureNote() { CaptureNoteImpl() { captured := "" source := "" - if !CaptureTextFromSelectionOrClipboard(&captured, &source) { - if (source = "clipboard_busy") - Notify("Flowkey", "📝 Note capture: clipboard busy — try again in a moment.") - else - Notify("Flowkey", "📝 Note capture: nothing to save (no selection, clipboard empty). Copy text first, then press Ctrl+Alt+N.") - return - } + selectionFound := CaptureSelectedText(&captured, &source) - ; Best-effort source app (for the YAML frontmatter only). + ; Best-effort source app for composer context. sourceApp := "" try sourceApp := WinGetProcessName("A") catch sourceApp := "" body := '{"args":{"text":"' EscapeJson(captured) - . '","source_app":"' EscapeJson(sourceApp) - . '","url":""}}' - result := RunActionViaDaemon("save_note", body) + . '","source_app":"' EscapeJson(sourceApp) '"}}' + result := RunActionViaDaemon("note_stage_capture", body) if (result = "") { Notify("Flowkey", "📝 Note capture: daemon unavailable.") return } - ; Daemon shows the final "Saved to inbox/" toast itself once - ; the background categorize thread finishes. This is just the AHK ack. - Notify("Flowkey", "📝 Note saved from " source " (" StrLen(captured) " chars) — categorizing…") + + OpenWebDashboard("notes") + if selectionFound + Notify("Flowkey", "📝 Selection ready in Notes (" StrLen(captured) " chars).") + else if (source = "clipboard_busy") + Notify("Flowkey", "📝 Clipboard busy — opened a blank note.") + else + Notify("Flowkey", "📝 Blank note ready.") } ; ---------------------------------------------------------------------------- diff --git a/scripts/lib/clipboard.ahk b/scripts/lib/clipboard.ahk index 5a25aa6..9d27b78 100644 --- a/scripts/lib/clipboard.ahk +++ b/scripts/lib/clipboard.ahk @@ -2,6 +2,39 @@ ; clipboard.ahk — shared selection/clipboard capture for hotkey actions. ; =========================================================================== +; Capture only text copied by a fresh synthetic Ctrl+C. This deliberately +; never falls back to the clipboard's prior text: note capture uses it so a +; blank selection opens a blank composer instead of silently saving something +; the user copied earlier. The user's clipboard is restored on every path. +CaptureSelectedText(&capturedText, &captureSource) { + clipSaved := "" + try { + clipSaved := ClipboardAll() + A_Clipboard := "" + } catch { + capturedText := "" + captureSource := "clipboard_busy" + return false + } + + fromSelection := "" + try { + Send("^c") + if ClipWait(1) { + try + fromSelection := A_Clipboard + catch + fromSelection := "" + } + } finally { + RestoreClipboard(clipSaved) + } + + capturedText := fromSelection + captureSource := (fromSelection != "") ? "selection" : "none" + return (fromSelection != "") +} + ; Returns true when text was captured. Sets capturedText and captureSource ; ("selection" or "clipboard"). Restores the user's clipboard on all paths — ; including exceptions mid-capture — and retries the restore briefly because diff --git a/tests/test_ffp_notifications.py b/tests/test_ffp_notifications.py index 632e2af..1e36165 100644 --- a/tests/test_ffp_notifications.py +++ b/tests/test_ffp_notifications.py @@ -48,9 +48,8 @@ def _notif(**overrides) -> dict: ("Flowkey", "Grammar engine not found (ffp-grammar-fix.exe / pyw.exe + grammar_fix.py).", "errors"), ("Flowkey", "No text returned.", "errors"), ("Flowkey", "Clipboard write failed.", "errors"), - ("Flowkey", "📝 Note capture: clipboard busy — try again in a moment.", "errors"), - ("Flowkey", "📝 Note capture: nothing to save (no selection, clipboard empty).", "errors"), ("Flowkey", "📝 Note capture: daemon unavailable.", "errors"), + ("Flowkey", "📝 Clipboard busy — opened a blank note.", "errors"), ("Flowkey", "💬 Ask: clipboard busy — try again in a moment.", "errors"), ("Flowkey", "💬 Ask: nothing to send (no selection, clipboard empty).", "errors"), ("Flowkey", "Ask: daemon unavailable.", "errors"), @@ -83,7 +82,8 @@ def _notif(**overrides) -> dict: ("Flowkey", "Prompt refined.", "action_result"), ("Flowkey", "Grammar fixed.", "action_result"), ("Flowkey", "✅ summarize done. · 1.2s · 45 tok/s", "action_result"), - ("Flowkey", "📝 Note saved from chrome.exe (412 chars) — categorizing…", "action_result"), + ("Flowkey", "📝 Selection ready in Notes (412 chars).", "action_result"), + ("Flowkey", "📝 Blank note ready.", "action_result"), ("Flowkey", "💬 Sent to chat (412 chars).", "action_result"), ] diff --git a/tests/test_notes_hotkey.py b/tests/test_notes_hotkey.py new file mode 100644 index 0000000..10716e5 --- /dev/null +++ b/tests/test_notes_hotkey.py @@ -0,0 +1,55 @@ +"""Static contracts for the AutoHotkey Notes quick-capture path.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GRAMMAR_AHK = (ROOT / "scripts" / "grammarFix.ahk").read_text(encoding="utf-8") +CLIPBOARD_AHK = (ROOT / "scripts" / "lib" / "clipboard.ahk").read_text(encoding="utf-8") + + +def _function_body(source: str, name: str) -> str: + match = re.search( + rf"(?ms)^{re.escape(name)}\([^)]*\) \{{(.*?)^\}}", + source, + ) + assert match, f"{name} function not found" + return match.group(1) + + +def test_note_hotkey_stages_selection_then_opens_notes_composer(): + body = _function_body(GRAMMAR_AHK, "CaptureNoteImpl") + + assert "CaptureSelectedText" in body + assert 'RunActionViaDaemon("note_stage_capture"' in body + assert 'OpenWebDashboard("notes")' in body + assert body.index('RunActionViaDaemon("note_stage_capture"') < body.index( + 'OpenWebDashboard("notes")' + ) + + +def test_note_hotkey_never_uses_legacy_immediate_save_or_clipboard_fallback(): + body = _function_body(GRAMMAR_AHK, "CaptureNoteImpl") + + assert "save_note" not in body + assert "CaptureTextFromSelectionOrClipboard" not in body + assert "priorClip" not in body + + +def test_selection_only_helper_restores_clipboard_without_reusing_prior_text(): + body = _function_body(CLIPBOARD_AHK, "CaptureSelectedText") + + assert "ClipboardAll()" in body + assert "RestoreClipboard(clipSaved)" in body + assert 'Send("^c")' in body + assert "priorClip" not in body + assert 'captureSource := (fromSelection != "") ? "selection" : "none"' in body + + +def test_no_selection_continues_to_stage_blank_composer(): + body = _function_body(GRAMMAR_AHK, "CaptureNoteImpl") + + assert "selectionFound := CaptureSelectedText" in body + assert "if !CaptureSelectedText" not in body + assert '"text":"' in body + assert 'Notify("Flowkey", "📝 Blank note ready.")' in body From a630249051f3447ef440f5f2656e6a783477ce2d Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Wed, 29 Jul 2026 17:57:25 -0400 Subject: [PATCH 06/12] T36: release Notes workspace as 2.5.0 Cites V18, V20, V47, V48, V49, V50, V51, V52, V53, V54, V55. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 15 ++++++++++++--- SPEC.md | 6 +++--- installer/installer.iss | 2 +- installer/sign.ps1 | 2 +- pyproject.toml | 2 +- scripts/_version.py | 2 +- tests/test_version_sync.py | 2 +- 8 files changed, 44 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eae6b3..489240c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +## 2.5.0 + +**Notes now works like a notepad, sticky-note wall, and vision board instead of a file browser.** Capture first, shape the note in an editor, and organize it without leaving the Notes tab. + +### Added + +- **Living Notes workspace.** Notes render as responsive sticky cards with dedicated types for notes, tasks, ideas, links, and read-later items. The composer/editor supports title, body, category, tags, color, status, due date, source link, pinning, Archive, and Trash. +- **Smart organization.** Search, type views, Pinned/Archive/Trash views, category facets, and tag facets work over an indexed vault feed. +- **Vision Board.** Editable sections and drag-and-drop card ordering are persisted separately in `.flowkey/board.json`, keyed by stable note IDs. Removing a board placement never mutates or deletes the note. +- **Recoverable removal.** The default delete action moves a note to Trash. Restore is immediate; permanent deletion is available only inside Trash and requires explicit confirmation. +- **Safe schema-v2 migration.** Existing Markdown notes gain a stable `note_id` and richer metadata without losing their body, source path, or unknown frontmatter. A backup is written to `.flowkey/backups/v1/` before the first rewrite. +- **Conflict-aware, atomic writes.** Notes and board state use atomic replacement and revisions, so a stale editor reports a conflict instead of overwriting newer work. + +### Changed + +- **The Notes tab contains notes only.** Vault, category, extraction, and local-model controls moved to Config → Notes & capture and save with the rest of Config. +- **`Ctrl+Alt+N` opens the Notes composer.** A fresh selection prefills the draft; no selection opens a blank composer. Prior clipboard contents are never used as an implicit fallback, and capture no longer saves before review. +- **Local-model enrichment cannot rewrite authored content.** It may fill only blank or generated metadata, preserving user-written titles and bodies. +- **Legacy note actions remain compatible.** Existing callers can continue reading or moving by relative path while the new workspace uses stable IDs. + +### Fixed + +- **Date-only due dates keep their calendar day.** A value such as `2026-08-04` displays as August 4 in every timezone instead of shifting to the prior day west of UTC. + ## 2.4.3 **`prompt:` handles vague requests properly.** A request too thin to break into requirements now produces a prompt that names what is missing, instead of handing the request back reworded. diff --git a/README.md b/README.md index fff3b0e..8647a32 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,15 @@ Flowkey is a Windows desktop assistant that adds local-LLM hotkeys for grammar f Everything runs locally through [FastFlowLM](https://fastflowlm.com) (AMD Ryzen AI NPU) or, on machines without the NPU, through [Ollama](https://ollama.com) (CPU/GPU) as a secondary provider. No cloud service, analytics, or telemetry is used by the app. -Current version: `2.4.3` +Current version: `2.5.0` + +## What's new in 2.5 + +- **Notes is now a real thinking workspace.** Capture plain notes, tasks, ideas, links, and read-later items as readable sticky cards. Search them, filter by type, category, or tag, and organize them with colors, pins, due dates, status, Archive, and a recoverable Trash. +- **The Vision Board keeps important notes alive.** Create and rename board sections, drag cards into the order you want, and keep unplaced notes nearby. Board placement never changes or deletes the underlying note. +- **`Ctrl+Alt+N` opens a quick composer.** Selected text is staged as an editable draft; with no selection, Flowkey opens a blank note. Nothing is saved until you choose to save it, and old clipboard text is never captured by accident. +- **Notes settings moved to Config.** The Notes tab is only for writing and organizing. Vault location, categories, extraction limits, and local-model enrichment now live together under Config → Notes & capture. +- **Existing Markdown notes migrate safely.** Notes receive stable IDs and richer metadata when first read. Flowkey preserves the body and unknown frontmatter, writes a v1 backup under the vault's `.flowkey/backups/v1/` directory before rewriting, and uses atomic saves with edit-conflict detection. ## What's new in 2.4 @@ -120,7 +128,7 @@ Launch the app with AutoHotkey v2: | `:` + `Ctrl+Shift+G` | Any mode you define in Dashboard → Config → Custom modes (e.g. `translate:`) | | `Ctrl+Alt+C` | Open chat (the Chat tab has a "My notes" toggle that grounds replies in your notes vault) | | `Ctrl+Shift+A` | Ask in chat with selected text | -| `Ctrl+Alt+N` | Capture a note | +| `Ctrl+Alt+N` | Open Notes quick capture (selected text prefills the composer; no selection opens it blank) | Prefix tip: put the keyword on the first line of your selection — `prompt: rough idea here` (or `prompt` on its own line, then the text). Without a prefix, `Ctrl+Shift+G` always runs a grammar fix. @@ -135,7 +143,8 @@ The dashboard is a web page served by the local daemon — open it from the tray - **Models:** pull models with live progress — pick a suggestion or type any name (on Ollama, anything from the [library](https://ollama.com/library) works); set active, remove. Suggestions are hardware-aware: detected RAM/VRAM caps the model size (e.g. 32 GB RAM → ~4B on the NPU; 8 GB VRAM → ~9B on the GPU), oversized models are hidden, and free-typing one asks before pulling. - **Benchmark:** works on both providers — `flm bench` on FastFlowLM (~10–20 min, NPU), timed generations with native metrics on Ollama (~1–3 min, server keeps running). - **History:** Telemetry view shows what ran and how fast; Exposed view shows stored request/result text only for rows captured while history storage was visible. The History tab includes the same redacted/visible storage toggle as Config. -- **Notes:** browse or search your vault. +- **Notes:** write and organize notes, tasks, ideas, links, and read-later items; use smart views, categories, tags, pins, Archive/Trash, or arrange cards on the Vision Board. +- **Notes settings:** Config → Notes & capture controls the vault, categories, link extraction, and optional local-model enrichment. The Notes tab itself contains no settings. - **Meetings:** connect the local [Quill](https://quillapp.com) app to search meetings, read AI digests (pre-computed after-hours), review action items (accept / reject), and generate a weekly review. Off by default — enable in Config → Meetings. - **Notifications:** per-event toggles, dedupe window, Do-Not-Disturb, and quiet hours; every toast (shown or muted) is logged to the Telemetry feed. diff --git a/SPEC.md b/SPEC.md index 1e1e5cc..9be0b3e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -17,7 +17,7 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - LLM: FastFlowLM NPU @ `:52625` | Ollama @ `:11434`, OpenAI-compat `POST /v1/chat/completions` - dashboard: daemon-served `scripts/ui/web/{index.html,app.js,styles.css}`, CSP `default-src 'self'` - paths: `scripts/paths.py` → USER_ROOT/{config,data,logs}; `_version.py` = version src of truth -- version: `2.4.3`; target `2.5.0` = living Notes workspace + vision board; repo `agr77one/Fastflow` +- version: `2.5.0` = living Notes workspace + vision board; repo `agr77one/Fastflow` - run tree = `flowkey-pub2` (worktree, branch `live`=origin/main). old `FastFlowPrompt_Local_Setup`=1.5.0 stale. ## §I interfaces @@ -56,7 +56,7 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - data: `data/{meeting_digests,meeting_action_status,meeting_skips,notifications,chat_threads}.jsonl` - autostart: HKCU Run `FastFlowPrompt` → bundled `AutoHotkey64.exe` + `grammarFix.ahk`; `FlowkeyGitSync` → `sync.ps1` - sched: Windows task `FlowkeyGitSync` daily 12:00 → `sync.ps1` (ff-only pull, guarded) -- ACTIONS count = 75 +- ACTIONS count = 85 ## §V invariants @@ -156,7 +156,7 @@ T32|x|daemon Notes v2 actions + backward-compatible read/move/delete + staged ca T33|x|move vault/categories/extraction/LLM Notes settings → Config single-save; Notes tab config-free|V3,V47 T34|x|Notes-only card workspace: composer, editor, smart views, filters, tags, archive/Trash, vision board drag/order|V5,V47,V48,V50,V52,V54,V55,V56 T35|x|capture hotkey → Notes quick composer w/ staged selection or blank body|V53 -T36|.|2.5.0 docs/version/migration + full release gates|V18,V20,V47,V48,V49,V50,V51,V52,V53,V54,V55 +T36|x|2.5.0 docs/version/migration + full release gates|V18,V20,V47,V48,V49,V50,V51,V52,V53,V54,V55 ``` ## §B bugs diff --git a/installer/installer.iss b/installer/installer.iss index 9e5a1a2..7399709 100644 --- a/installer/installer.iss +++ b/installer/installer.iss @@ -41,7 +41,7 @@ #define AppURL "https://github.com/agr77one/Fastflow" #define AppExeName "Flowkey.exe" ; symbolic — actual launchers below ; Keep in lockstep with scripts\_version.py. -#define AppVersion "2.4.3" +#define AppVersion "2.5.0" [Setup] AppId={{8A4F1E6C-9B3D-4E62-9F7A-FASTFLOW140}} diff --git a/installer/sign.ps1 b/installer/sign.ps1 index fdd8a31..4a4d6ed 100644 --- a/installer/sign.ps1 +++ b/installer/sign.ps1 @@ -52,7 +52,7 @@ .EXAMPLE # Sign the installer $env:FFP_SIGN_PFX_PASSWORD = "ChangeMe!" - .\sign.ps1 -FilePath ..\out\Flowkey-Setup-2.4.3.exe + .\sign.ps1 -FilePath ..\out\Flowkey-Setup-2.5.0.exe #> [CmdletBinding(DefaultParameterSetName = "Sign")] diff --git a/pyproject.toml b/pyproject.toml index f48d8c5..d30a43c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ build-backend = "setuptools.build_meta" [project] name = "fastflowprompt" -version = "2.4.3" +version = "2.5.0" description = "Local-LLM-powered grammar fix, prompt rewrite, chat, and dashboard for Windows." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/_version.py b/scripts/_version.py index b312e02..ec7a6d0 100644 --- a/scripts/_version.py +++ b/scripts/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the app version. Read by grammar_fix.py.""" -__version__ = "2.4.3" +__version__ = "2.5.0" diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index b88b3fe..4892a9b 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -32,4 +32,4 @@ def test_v18_release_version_is_synchronized(): ), } - assert set(versions.values()) == {"2.4.3"}, versions + assert set(versions.values()) == {"2.5.0"}, versions From 7bfb2408fea394289d981faa9aac25da87e228ad Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Wed, 29 Jul 2026 22:40:14 -0400 Subject: [PATCH 07/12] T37: repair Quill transcript digest pipeline --- SPEC.md | 14 ++++++-- scripts/ffp_meetings.py | 20 ++++++++++++ scripts/ffp_quill.py | 55 +++++++++++++++++++++++++++++--- tests/test_ffp_meetings.py | 50 +++++++++++++++++++++++++++++ tests/test_ffp_quill.py | 65 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index 9be0b3e..626559a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -29,6 +29,7 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - action: `notes_query {query?,kind?,status?,category?,tag?,sort?,limit?,offset?}` → `{results,count,facets}` - action: `note_create {title?,body?,kind?,category?,tags?,color?,due?,source?}` → note - action: `note_update {note_id,revision,patch}` → note | conflict +- action: `note_organize {note_id,revision?}` → note; local LLM may update category/suggested metadata only - action: `note_trash {note_id}` / `note_restore {note_id}` → note; `note_delete {note_id,permanent:true}` → deleted - action: `notes_board_get` → `{board,placements}`; `notes_board_save {revision,board}` → board | conflict - action: `note_stage_capture {text?,source_app?}` / `note_take_staged` → quick-capture payload @@ -51,12 +52,12 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - action: `meeting_actions_list {range:week|month}` → `{range,items:[{id,text,owner,status,...}],counts}` - action: `meeting_action_set_status {id,status:pending|accepted|rejected}` → `{ok}` - action: `meeting_week_summary {week_offset}` → `{ok,week_label,meeting_count,summary}` -- mcp: Quill @ `http://127.0.0.1:19532/mcp` — Streamable-HTTP, SSE `data:`, `Mcp-Session-Id` header; init→notifications/initialized→tools/call +- mcp: Quill @ `http://127.0.0.1:19532/mcp` — Streamable-HTTP, SSE `data:`, `Mcp-Session-Id` header; init→notifications/initialized→tools/call; `get_transcript {meeting_id,include_private_notes:true}`; tool `isError` → typed failure - cmd: `flm serve --pmode turbo --host 127.0.0.1 --port 52625` - data: `data/{meeting_digests,meeting_action_status,meeting_skips,notifications,chat_threads}.jsonl` - autostart: HKCU Run `FastFlowPrompt` → bundled `AutoHotkey64.exe` + `grammarFix.ahk`; `FlowkeyGitSync` → `sync.ps1` - sched: Windows task `FlowkeyGitSync` daily 12:00 → `sync.ps1` (ff-only pull, guarded) -- ACTIONS count = 85 +- ACTIONS count = 86 ## §V invariants @@ -116,6 +117,10 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - V54: note writes + board writes atomic; stale `revision` → conflict, ⊥ overwrite - V55: note card/editor controls keyboard reachable; desktop split workspace + ≤720px stacked layout - V56: note `due` date-only value renders same calendar day ∀ timezone; ⊥ UTC date shift +- V57: Quill transcript call matches discovered schema (`meeting_id` + `include_private_notes`); MCP JSON-RPC/tool `isError` ∨ validation payload → typed failure, ⊥ LLM input/cache; poisoned legacy digest → ⊥ idempotency hit, eligible reprocess +- V58: model-created note category accepted ⟺ config opt-in ∧ `is_new` ∧ high confidence ∧ safe normalized ≤2-segment slug; accepted category atomically deduped+sorted into cfg; otherwise Inbox + suggestion; V49 holds +- V59: Config → keyboard-accessible section nav + collapsible groups + one selected section visible + sticky Save/Revert; view state local-only persisted; ≤720px responsive +- V60: future Activity workspace may join Telemetry+History + explicit `Save as note`; Notes remains authored knowledge; V6,V25,V26 hold ## §T tasks @@ -157,6 +162,10 @@ T33|x|move vault/categories/extraction/LLM Notes settings → Config single-save T34|x|Notes-only card workspace: composer, editor, smart views, filters, tags, archive/Trash, vision board drag/order|V5,V47,V48,V50,V52,V54,V55,V56 T35|x|capture hotkey → Notes quick composer w/ staged selection or blank body|V53 T36|x|2.5.0 docs/version/migration + full release gates|V18,V20,V47,V48,V49,V50,V51,V52,V53,V54,V55 +T37|x|repair Quill transcript schema/error handling + poison-cache retry + re-digest latest|V12,V14,V20,V23,V57 +T38|.|guarded local-model category creation + sorted category manager + note organize action|V3,V7,V20,V49,V58 +T39|.|compact Config section navigation + collapsible cards + sticky save|V5,V20,V55,V59 +T40|.|Activity workspace: merge Telemetry+History, card/detail UI, explicit Save as note|V5,V6,V25,V26,V60 ``` ## §B bugs @@ -204,4 +213,5 @@ B36|2026-07-27|keep-warm thread ⊥ aware of benchmarks: warms/reloads active mo B37|2026-07-27|FLM returns HTTP **200** + `{"error":"Failed to load model!"}`; `_call_openai_compatible`/`ffp_chat` read only `choices` ∴ real cause discarded → "Local LLM returned no usable text"|V43; surface the error body B34|2026-07-27|self-caught: `_active_model_health` tested membership vs `_provider_list("all")`; ollama "all" = installed+suggested (`ffp_provider_runtime:80`) ∴ never-pulled model → false `installed=True` (⊥ warn)|V39; trust `details` (unfiltered, authoritative) else re-list w/ `installed` filter B41|2026-07-29|Notes due `2026-08-04` parsed as UTC midnight → EDT displayed Aug 3|V56; parse date-only @ local noon +B42|2026-07-29|Quill `get_transcript` sent `id`; live schema requires `meeting_id`+`include_private_notes`; `call_tool` ignored `isError` ∴ 133-char validation error fed to LLM + cached as digest|V57 ``` diff --git a/scripts/ffp_meetings.py b/scripts/ffp_meetings.py index 7272758..d03b550 100644 --- a/scripts/ffp_meetings.py +++ b/scripts/ffp_meetings.py @@ -140,6 +140,23 @@ def should_run_batch(meetings_cfg: dict, now_dt, idle_seconds: float | None) -> # ---------- digest store -------------------------------------------------------------- +def _digest_is_poisoned(rec: dict) -> bool: + """Recognize legacy digests generated from Quill validation-error text.""" + digest = str(rec.get("digest_md") or "").strip().lower() + try: + context_chars = int(rec.get("context_chars") or 0) + except (TypeError, ValueError): + context_chars = 0 + validation_marker = ( + "validation_error" in digest + or "invalid meeting_id input parameter" in digest + or ("meeting_id" in digest and "invalid input parameter" in digest) + ) + return digest.startswith("error:") or bool( + validation_marker and (not context_chars or context_chars <= 512) + ) + + def load_digests() -> list[dict]: import json if not DIGESTS_PATH.exists(): @@ -158,6 +175,9 @@ def load_digests() -> list[dict]: mid = row.get("meeting_id") if not mid: continue + if _digest_is_poisoned(row): + log.warning("ignoring poisoned meeting digest for %s", mid) + continue prev = latest.get(mid) if prev is None or str(row.get("processed_at") or "") >= str(prev.get("processed_at") or ""): latest[mid] = row diff --git a/scripts/ffp_quill.py b/scripts/ffp_quill.py index dba6183..ef7fdd2 100644 --- a/scripts/ffp_quill.py +++ b/scripts/ffp_quill.py @@ -26,6 +26,15 @@ PROTOCOL_VERSION = "2025-06-18" +class QuillToolError(RuntimeError): + """Quill accepted the MCP request but rejected the tool invocation.""" + + def __init__(self, tool: str, message: str): + self.tool = str(tool or "") + self.message = str(message or "unknown Quill tool error").strip() + super().__init__(f"{self.tool}: {self.message}") + + def _parse_sse(body: str) -> list[dict]: """Extract JSON objects from SSE ``data:`` lines (Quill replies as SSE).""" out: list[dict] = [] @@ -93,7 +102,7 @@ def connect(self) -> bool: return False def call_tool(self, name: str, arguments: dict) -> str: - """Call an MCP tool, return its text content ('' on error/empty).""" + """Call an MCP tool; transport failures stay soft, tool failures do not.""" if not self.session_id and not self.connect(): return "" try: @@ -101,8 +110,21 @@ def call_tool(self, name: str, arguments: dict) -> str: except Exception as exc: log.warning("Quill tool %s failed: %s", name, exc) return "" - content = ((res or {}).get("result") or {}).get("content") or [] - return "\n".join(c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text") + response = res or {} + if response.get("error"): + error = response["error"] + message = error.get("message") if isinstance(error, dict) else str(error) + raise QuillToolError(name, message or "JSON-RPC error") + result = response.get("result") or {} + content = result.get("content") or [] + text = "\n".join( + c.get("text", "") + for c in content + if isinstance(c, dict) and c.get("type") == "text" + ) + if result.get("isError") or _is_validation_error_text(text): + raise QuillToolError(name, _tool_error_message(text)) + return text # ---------- parsing helpers (Quill's XML-ish tool output -> dicts) -------------------- @@ -112,6 +134,28 @@ def call_tool(self, name: str, arguments: dict) -> str: _TITLE_RE = re.compile(r"(.*?)", re.DOTALL) +def _is_validation_error_text(text: str) -> bool: + value = str(text or "").strip().lower() + return value.startswith("error:") and ( + "validation_error" in value + or "invalid input parameter" in value + or '"invalid input"' in value + ) + + +def _tool_error_message(text: str) -> str: + value = str(text or "").strip() + if value.lower().startswith("error:"): + value = value[6:].strip() + try: + payload = json.loads(value) + except (json.JSONDecodeError, TypeError): + return value or "Quill tool returned an error" + if isinstance(payload, dict): + return str(payload.get("message") or payload.get("code") or value) + return value or "Quill tool returned an error" + + def _parse_meetings(text: str) -> list[dict]: meetings: list[dict] = [] for attr_blob, inner in _MEETING_RE.findall(text or ""): @@ -181,4 +225,7 @@ def get_minutes(meeting_id: str, *, url: str = DEFAULT_MCP_URL, client: QuillCli def get_transcript(meeting_id: str, *, url: str = DEFAULT_MCP_URL, client: QuillClient | None = None) -> str: c = client or QuillClient(url) - return clean_text(c.call_tool("get_transcript", {"id": meeting_id})) + return clean_text(c.call_tool( + "get_transcript", + {"meeting_id": meeting_id, "include_private_notes": True}, + )) diff --git a/tests/test_ffp_meetings.py b/tests/test_ffp_meetings.py index 46c41bd..1ccdc5a 100644 --- a/tests/test_ffp_meetings.py +++ b/tests/test_ffp_meetings.py @@ -120,6 +120,56 @@ def test_digests_list(): assert "digest_md" not in out["digests"][0] # list is summary-only +def test_poisoned_validation_digest_is_not_a_cache_hit(): + M.save_digest({ + "meeting_id": "poisoned", + "title": "Real meeting", + "processed_at": "2026-07-29T22:17:55", + "source": "transcript", + "context_chars": 133, + "digest_md": ( + "## Summary\n- (not discussed)\n" + "## Goals\n- (not discussed)\n" + "## Action items\n" + "- [unassigned] Review invalid meeting_id input parameters" + ), + }) + + assert M.digest_exists("poisoned") is False + assert M.get_digest("poisoned") == { + "found": False, + "meeting_id": "poisoned", + } + assert M.list_digests() == {"digests": [], "count": 0} + + +def test_batch_reprocesses_poisoned_validation_digest(): + meeting = { + "id": "poisoned", + "title": "Real meeting", + "date": "2026-07-29T13:59:24Z", + } + M.save_digest({ + "meeting_id": meeting["id"], + "title": meeting["title"], + "processed_at": "2026-07-29T22:17:55", + "source": "transcript", + "context_chars": 133, + "digest_md": "- [unassigned] Review invalid meeting_id input parameters", + }) + + result = M.run_batch( + _cfg(), + client=FakeQuill([meeting], minutes="## Notes\n- fixed parcel routing"), + llm_call=lambda messages: "## Summary\n- Fixed parcel routing", + ) + + assert result["processed"] == 1 + assert M.get_digest("poisoned")["digest_md"] == ( + "## Summary\n- Fixed parcel routing" + ) + + # ---------- process / batch ----------------------------------------------------------- def test_process_meeting_uses_minutes_when_available(): diff --git a/tests/test_ffp_quill.py b/tests/test_ffp_quill.py index c1fa19c..f8ad88e 100644 --- a/tests/test_ffp_quill.py +++ b/tests/test_ffp_quill.py @@ -3,6 +3,7 @@ from __future__ import annotations import ffp_quill +import pytest def test_parse_meetings(): @@ -47,3 +48,67 @@ def test_parse_sse(): ) objs = ffp_quill._parse_sse(body) assert objs == [{"result": {"ok": True}, "id": 1}] + + +def test_call_tool_raises_typed_error_for_mcp_is_error(monkeypatch): + client = ffp_quill.QuillClient() + client.session_id = "test" + monkeypatch.setattr( + client, + "_post", + lambda *args, **kwargs: { + "result": { + "isError": True, + "content": [{ + "type": "text", + "text": ( + 'Error: {"code":"validation_error",' + '"message":"Invalid input parameters"}' + ), + }], + }, + }, + ) + + with pytest.raises(ffp_quill.QuillToolError, match="Invalid input parameters"): + client.call_tool("get_transcript", {"id": "old-contract"}) + + +def test_call_tool_rejects_validation_payload_without_is_error(monkeypatch): + client = ffp_quill.QuillClient() + client.session_id = "test" + monkeypatch.setattr( + client, + "_post", + lambda *args, **kwargs: { + "result": { + "content": [{ + "type": "text", + "text": ( + 'Error: {"code":"validation_error",' + '"message":"Invalid input parameters"}' + ), + }], + }, + }, + ) + + with pytest.raises(ffp_quill.QuillToolError): + client.call_tool("get_transcript", {}) + + +def test_get_transcript_uses_current_quill_schema(): + class FakeClient: + def __init__(self): + self.call = None + + def call_tool(self, name, arguments): + self.call = (name, arguments) + return "real content" + + client = FakeClient() + assert ffp_quill.get_transcript("meeting-1", client=client) == "real content" + assert client.call == ( + "get_transcript", + {"meeting_id": "meeting-1", "include_private_notes": True}, + ) From e1a5705c1e162851d3af6cc642328c0be9b9f853 Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Wed, 29 Jul 2026 22:50:19 -0400 Subject: [PATCH 08/12] T38: add guarded smart note categories --- SPEC.md | 2 +- config/grammar_hotkey.config.example.json | 1 + scripts/ffp_config.py | 55 ++++- scripts/ffp_daemon.py | 16 +- scripts/grammar_fix.py | 1 + scripts/notes.py | 191 +++++++++++++++++- scripts/ui/web/app.js | 121 ++++++++++- scripts/ui/web/index.html | 12 +- scripts/ui/web/styles.css | 44 ++++ .../grammar_hotkey.config.example.json | 1 + setup/defaults/grammar_hotkey.config.json | 1 + tests/test_config_seeds.py | 5 +- tests/test_ffp_config.py | 34 ++++ tests/test_ffp_daemon.py | 8 +- tests/test_notes_capture.py | 110 ++++++++++ tests/test_notes_config_location.py | 13 ++ tests/test_notes_daemon_actions.py | 28 +++ tests/test_notes_workspace_ui.py | 2 + 18 files changed, 608 insertions(+), 37 deletions(-) diff --git a/SPEC.md b/SPEC.md index 626559a..b014802 100644 --- a/SPEC.md +++ b/SPEC.md @@ -163,7 +163,7 @@ T34|x|Notes-only card workspace: composer, editor, smart views, filters, tags, a T35|x|capture hotkey → Notes quick composer w/ staged selection or blank body|V53 T36|x|2.5.0 docs/version/migration + full release gates|V18,V20,V47,V48,V49,V50,V51,V52,V53,V54,V55 T37|x|repair Quill transcript schema/error handling + poison-cache retry + re-digest latest|V12,V14,V20,V23,V57 -T38|.|guarded local-model category creation + sorted category manager + note organize action|V3,V7,V20,V49,V58 +T38|x|guarded local-model category creation + sorted category manager + note organize action|V3,V7,V20,V49,V58 T39|.|compact Config section navigation + collapsible cards + sticky save|V5,V20,V55,V59 T40|.|Activity workspace: merge Telemetry+History, card/detail UI, explicit Save as note|V5,V6,V25,V26,V60 ``` diff --git a/config/grammar_hotkey.config.example.json b/config/grammar_hotkey.config.example.json index eeb97b5..941a7df 100644 --- a/config/grammar_hotkey.config.example.json +++ b/config/grammar_hotkey.config.example.json @@ -94,6 +94,7 @@ "fetch_timeout_seconds": 8, "max_extracted_chars": 2000, "low_confidence_to_inbox": true, + "allow_new_categories": true, "generate_title": true, "generate_summary": true }, diff --git a/scripts/ffp_config.py b/scripts/ffp_config.py index 08aafe3..e2e2026 100644 --- a/scripts/ffp_config.py +++ b/scripts/ffp_config.py @@ -17,7 +17,7 @@ log = logging.getLogger("ffp.config") -_config_lock = threading.Lock() +_config_lock = threading.RLock() CLAUDE_PROMPT_SYSTEM_PROMPT_V1 = ( "Rewrite the user text as a Claude-ready prompt. " @@ -92,6 +92,23 @@ "min_chunk_chars": 700, }, "prompt_builder": copy.deepcopy(ffp_prompt_builder.DEFAULT_PROMPT_BUILDER_CONFIG), + "notes": { + "vault_dir": r"%USERPROFILE%\Documents\FastFlowPrompt Notes", + "categories": [ + "work/technical", + "work/managerial", + "work/career", + "research", + "personal", + "ideas", + ], + "fetch_timeout_seconds": 8, + "max_extracted_chars": 2000, + "low_confidence_to_inbox": True, + "allow_new_categories": True, + "generate_title": True, + "generate_summary": True, + }, "dictionary": { "protected_words": [], }, @@ -232,18 +249,35 @@ def _enforce_builtin_mode_prompts(cfg: dict) -> None: user_mode["system_prompt"] = default_mode["system_prompt"] +def _save_config_unlocked(config_path: Path, cfg: dict) -> None: + payload = json.dumps(cfg, ensure_ascii=False, indent=2) + "\n" + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + tmp = config_path.with_suffix(config_path.suffix + ".tmp") + tmp.write_text(payload, encoding="utf-8") + os.replace(tmp, config_path) + except OSError as exc: + log.warning("failed to save config %s: %s", config_path, exc) + raise + + def save_config(config_path: Path, cfg: dict) -> None: """Atomic write with a module lock to avoid torn JSON under concurrency.""" - payload = json.dumps(cfg, ensure_ascii=False, indent=2) + "\n" with _config_lock: - try: - config_path.parent.mkdir(parents=True, exist_ok=True) - tmp = config_path.with_suffix(config_path.suffix + ".tmp") - tmp.write_text(payload, encoding="utf-8") - os.replace(tmp, config_path) - except OSError as exc: - log.warning("failed to save config %s: %s", config_path, exc) - raise + _save_config_unlocked(config_path, cfg) + + +def update_config(config_path: Path, mutator) -> dict: + """Atomically load, mutate, and replace config without a stale overwrite.""" + with _config_lock: + cfg = load_config(config_path) + replacement = mutator(cfg) + if replacement is not None: + if not isinstance(replacement, dict): + raise TypeError("config mutator must return a dict or None") + cfg = replacement + _save_config_unlocked(config_path, cfg) + return cfg def deep_merge(dst: dict, src: dict) -> None: @@ -378,6 +412,7 @@ def normalize_prompt_builder_config(cfg: dict) -> dict: "min_chunk_chars", }) _PATCH_NOTES_KEYS = frozenset({ + "allow_new_categories", "categories", "fetch_timeout_seconds", "generate_summary", diff --git a/scripts/ffp_daemon.py b/scripts/ffp_daemon.py index 720ebbb..788178f 100644 --- a/scripts/ffp_daemon.py +++ b/scripts/ffp_daemon.py @@ -702,6 +702,19 @@ def _act_note_update(args: dict) -> dict: ) +def _act_note_organize(args: dict) -> dict: + import notes + raw_revision = args.get("revision") + try: + revision = int(raw_revision) if raw_revision is not None else None + except (TypeError, ValueError): + raise ValueError("revision must be an integer") from None + return notes.organize_note( + str(args.get("note_id") or ""), + revision, + ) + + def _act_note_archive(args: dict) -> dict: import notes raw_revision = args.get("revision") @@ -1019,6 +1032,7 @@ def _act_meeting_week_summary(args: dict) -> dict: "note_get": _act_note_get, "note_create": _act_note_create, "note_update": _act_note_update, + "note_organize": _act_note_organize, "note_archive": _act_note_archive, "note_trash": _act_note_trash, "note_restore": _act_note_restore, @@ -1070,7 +1084,7 @@ def _act_meeting_week_summary(args: dict) -> dict: "pull_model", "remove_model", "apply_config_patch", "update_apply", "set_autostart", "bench_start", "pull_start", "chat_send", "chat_thread_delete", "chat_stage_selection", "chat_take_staged", - "note_create", "note_update", "note_archive", "note_trash", "note_restore", + "note_create", "note_update", "note_organize", "note_archive", "note_trash", "note_restore", "note_move", "note_delete", "notes_board_save", "note_stage_capture", "note_take_staged", "notify_gate", # writes the notifications log + updates dedupe state diff --git a/scripts/grammar_fix.py b/scripts/grammar_fix.py index 8c2ef77..fdc8048 100644 --- a/scripts/grammar_fix.py +++ b/scripts/grammar_fix.py @@ -929,6 +929,7 @@ def build_config_snapshot() -> dict: "fetch_timeout_seconds": int(notes_cfg.get("fetch_timeout_seconds") or 8), "max_extracted_chars": int(notes_cfg.get("max_extracted_chars") or 2000), "low_confidence_to_inbox": bool(notes_cfg.get("low_confidence_to_inbox", True)), + "allow_new_categories": bool(notes_cfg.get("allow_new_categories", True)), "generate_title": bool(notes_cfg.get("generate_title", True)), "generate_summary": bool(notes_cfg.get("generate_summary", True)), }, diff --git a/scripts/notes.py b/scripts/notes.py index d4791d0..117f4b1 100644 --- a/scripts/notes.py +++ b/scripts/notes.py @@ -43,6 +43,7 @@ from pathlib import Path from typing import Any +import ffp_config import grammar_fix log = logging.getLogger("ffp.notes") @@ -111,10 +112,66 @@ def _categories() -> list[str]: if not cat or cat == INBOX: continue try: - out.append(_safe_category(str(cat))) + clean = _safe_category(str(cat)) except ValueError: continue - return out or list(DEFAULT_CATEGORIES) + if clean not in out: + out.append(clean) + return sorted(out or list(DEFAULT_CATEGORIES), key=str.casefold) + + +def _allow_new_categories() -> bool: + return bool(_notes_cfg().get("allow_new_categories", True)) + + +_CATEGORY_SEGMENT_RE = re.compile(r"[^a-z0-9]+") + + +def _normalize_model_category(category: str) -> str: + """Return a safe one/two-level slug for a model-proposed category.""" + raw = str(category or "").strip().replace("\\", "/").strip("/") + parts = [part.strip() for part in raw.split("/")] + if not parts or len(parts) > 2 or any(not part for part in parts): + return "" + normalized: list[str] = [] + for part in parts: + segment = _CATEGORY_SEGMENT_RE.sub("-", part.lower()).strip("-") + segment = segment[:32].rstrip("-") + if not segment: + return "" + normalized.append(segment) + candidate = "/".join(normalized) + if candidate == INBOX or len(candidate) > 65: + return "" + try: + return _safe_category(candidate) + except ValueError: + return "" + + +def _register_model_category(category: str) -> bool: + """Atomically add a guarded model-created category to user config.""" + clean = _normalize_model_category(category) + if not clean: + return False + + def add_category(cfg: dict) -> None: + notes_cfg = cfg.setdefault("notes", {}) + existing: list[str] = [] + for item in notes_cfg.get("categories") or DEFAULT_CATEGORIES: + try: + safe = _safe_category(str(item)) + except ValueError: + continue + if safe != INBOX and safe not in existing: + existing.append(safe) + if clean not in existing: + existing.append(clean) + notes_cfg["categories"] = sorted(existing, key=str.casefold) + + updated = ffp_config.update_config(grammar_fix.CONFIG_PATH, add_category) + grammar_fix.refresh_runtime_config() + return clean in ((updated.get("notes") or {}).get("categories") or []) def _fetch_timeout() -> int: @@ -305,23 +362,33 @@ def _slug_tokens_from_url(url: str) -> list[str]: def _build_categorize_prompt(text: str, source_app: str, url: str, slug_tokens: list[str], fetched_title: str, - fetched_body: str, categories: list[str]) -> str: + fetched_body: str, categories: list[str], + allow_new: bool = False) -> str: cats_block = "\n".join(f" - {c}" for c in categories) + f"\n - {INBOX}" parts = [ "You categorize a captured note.", - "Pick EXACTLY ONE folder from the list below.", + "Prefer EXACTLY ONE existing folder from the list below.", f"If you are unsure, choose '{INBOX}'.", "", "Available folders:", cats_block, "", "Output ONLY a JSON object matching this schema, no commentary, no Markdown fences:", - '{"category":"","confidence":"high|medium|low",' + '{"category":"","is_new":false,' + '"confidence":"high|medium|low",' '"title":"",' '"summary":"<1-2 paragraph summary, third person>"}', "", f"Source app: {source_app or 'unknown'}", ] + if allow_new: + parts[2:2] = [ + "If no existing folder fits, you MAY propose one new lowercase slug " + "with at most two levels (example: learning/python) and set is_new=true.", + "Only propose a new folder when confidence is high; otherwise use inbox.", + ] + else: + parts.insert(2, "Do not invent folders; is_new must be false.") if url: parts.append(f"URL: {url}") if slug_tokens: @@ -339,10 +406,12 @@ def _llm_categorize(text: str, source_app: str, url: str, """Returns {category, confidence, title, summary}. Falls back gracefully on LLM failure or invalid JSON.""" cats = _categories() + allow_new = _allow_new_categories() slug_tokens = _slug_tokens_from_url(url) if url else [] user_content = _build_categorize_prompt( text=text, source_app=source_app, url=url, slug_tokens=slug_tokens, fetched_title=fetched_title, fetched_body=fetched_body, categories=cats, + allow_new=allow_new, ) system_prompt = ( "You are a strict categorizer. Output only valid JSON matching the schema. " @@ -356,6 +425,8 @@ def _llm_categorize(text: str, source_app: str, url: str, except Exception as e: log.warning("categorize LLM call failed: %s", e) return {"category": INBOX, "confidence": "low", + "suggested_category": INBOX, "is_new": False, + "created_category": False, "title": _fallback_title(text, fetched_title), "summary": "(LLM unavailable; left in inbox)"} @@ -363,22 +434,48 @@ def _llm_categorize(text: str, source_app: str, url: str, if not parsed: log.warning("categorize returned unparseable JSON; raw=%r", raw[:200]) return {"category": INBOX, "confidence": "low", + "suggested_category": INBOX, "is_new": False, + "created_category": False, "title": _fallback_title(text, fetched_title), "summary": "(could not parse categorization output)"} - # Validate category against the allowed list. - chosen = str(parsed.get("category") or "").strip() - if chosen not in cats and chosen != INBOX: - log.info("LLM picked unknown category %r; falling back to inbox", chosen) - chosen = INBOX confidence = str(parsed.get("confidence") or "low").strip().lower() if confidence not in {"high", "medium", "low"}: confidence = "low" + raw_category = str(parsed.get("category") or "").strip().replace("\\", "/") + requested_new = parsed.get("is_new") is True + created_category = False + suggested = raw_category if raw_category in cats or raw_category == INBOX else ( + _normalize_model_category(raw_category) + ) + if raw_category in cats or raw_category == INBOX: + chosen = raw_category + elif ( + requested_new + and allow_new + and confidence == "high" + and suggested + and _register_model_category(suggested) + ): + chosen = suggested + created_category = True + else: + log.info( + "LLM category %r not accepted (new=%s allow=%s confidence=%s)", + raw_category, + requested_new, + allow_new, + confidence, + ) + chosen = INBOX if confidence == "low" and _low_conf_to_inbox(): chosen = INBOX return { "category": chosen, + "suggested_category": suggested or INBOX, + "is_new": requested_new, + "created_category": created_category, "confidence": confidence, "title": _clean_title(parsed.get("title"), text, fetched_title), "summary": str(parsed.get("summary") or "").strip(), @@ -667,6 +764,8 @@ def _note_record(path: Path, metadata: dict, body: str, include_body: bool = Fal "due": str(metadata.get("due") or ""), "source": str(metadata.get("source") or ""), "summary": str(metadata.get("summary") or ""), + "suggested_category": str(metadata.get("suggested_category") or ""), + "confidence": str(metadata.get("confidence") or ""), "created": str(metadata.get("created") or ""), "updated": str(metadata.get("updated") or ""), "excerpt": excerpt, @@ -1063,6 +1162,72 @@ def update_note(note_id: str, revision: int | None, patch: dict) -> dict: return _note_record(destination, merged, new_body, include_body=True) +def organize_note(note_id: str, revision: int | None = None) -> dict: + """Use the local model to re-file a note without rewriting authored text.""" + initial = get_note(note_id) + if not initial.get("ok"): + return initial + if initial.get("status") == "trashed": + return {"ok": False, "error": "trashed notes cannot be organized"} + categorized = _llm_categorize( + text="\n\n".join(filter(None, [ + str(initial.get("title") or ""), + str(initial.get("body") or ""), + ])), + source_app="dashboard", + url=str(initial.get("source") or ""), + fetched_title="", + fetched_body="", + ) + + with _NOTES_LOCK: + source_path = _find_note_path(note_id, include_trash=True) + if source_path is None: + return {"ok": False, "error": "note not found"} + metadata, body, text = _ensure_note_schema(source_path) + if metadata.get("status") == "trashed": + return {"ok": False, "error": "trashed notes cannot be organized"} + current_revision = int(metadata.get("revision") or 1) + if revision is not None and int(revision) != current_revision: + return { + "ok": False, + "error": "note changed while the local model was organizing it", + "conflict": True, + "note": _note_record( + source_path, + metadata, + body, + include_body=True, + ), + } + + category = _safe_category(str(categorized.get("category") or INBOX)) + updates = { + "category": category, + "suggested_category": str( + categorized.get("suggested_category") or category + ), + "confidence": str(categorized.get("confidence") or "low"), + "category_created": bool(categorized.get("created_category")), + "updated": _now_iso(), + "revision": current_revision + 1, + } + destination = _vault_subpath(category, source_path.name) + if destination.exists() and destination.resolve() != source_path.resolve(): + destination = _vault_subpath( + category, + f"{source_path.stem}-{uuid.uuid4().hex[:6]}{source_path.suffix}", + ) + rewritten = _merge_frontmatter(text, updates, body=body) + _atomic_write_text(destination, rewritten) + if destination.resolve() != source_path.resolve() and source_path.exists(): + source_path.unlink() + merged = dict(metadata) + merged.update(updates) + _invalidate_index() + return _note_record(destination, merged, body, include_body=True) + + def archive_note(note_id: str, revision: int | None = None) -> dict: return update_note(note_id, revision, {"status": "archived"}) @@ -1422,7 +1587,11 @@ def _categorize_in_background(stub_path: Path, note_id: str, text: str, current_revision = int(metadata.get("revision") or 1) updates: dict[str, Any] = { "confidence": categorized["confidence"], - "suggested_category": categorized["category"], + "suggested_category": categorized.get( + "suggested_category", + categorized["category"], + ), + "category_created": bool(categorized.get("created_category")), "summary": categorized["summary"] if _wants_summary() else "", "fetch_status": ( "error" if fetched and fetched.get("error") diff --git a/scripts/ui/web/app.js b/scripts/ui/web/app.js index e3e681c..13f4472 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -1051,6 +1051,7 @@ function setEditorReadOnly(trashed) { $(id).disabled = trashed; } $("ne-save").hidden = trashed; + $("ne-organize").disabled = trashed || !notesState.current?.note_id; $("ne-archive").hidden = trashed || notesState.current?.status === "archived"; $("ne-trash").hidden = trashed; $("ne-restore").hidden = !trashed; @@ -1199,6 +1200,44 @@ async function saveNoteEditor() { } } +async function organizeCurrentNote() { + if (!notesState.current?.note_id) { + setEditorStatus("Save the note before organizing it.", false); + return; + } + const button = $("ne-organize"); + button.disabled = true; + setEditorStatus("Organizing with the local model…"); + try { + const organized = await action("note_organize", { + note_id: notesState.current.note_id, + revision: notesState.current.revision, + }); + if (!organized.ok) { + if (organized.conflict && organized.note) { + notesState.current = organized.note; + fillNoteEditor(organized.note); + } + setEditorStatus(organized.error || "Organize failed.", false); + return; + } + notesState.current = organized; + await loadNotes(false); + fillNoteEditor(organized); + const suggestion = organized.suggested_category; + const detail = organized.category === "inbox" + && suggestion && suggestion !== "inbox" + ? ` Kept in Inbox; suggested ${suggestion}.` + : ` Filed in ${organized.category}.`; + setEditorStatus(`Organized.${detail}`); + } catch (error) { + setEditorStatus(`Organize failed: ${error.message}`, false); + } finally { + button.disabled = !notesState.current?.note_id + || notesState.current?.status === "trashed"; + } +} + async function archiveCurrentNote() { if (!notesState.current?.note_id) return; try { @@ -1320,25 +1359,87 @@ async function loadNotes(takeStaged = true) { function populateNotesConfig(notes) { const cfg = notes || {}; $("notes-vault").value = cfg.vault_dir || ""; - $("notes-categories").value = (cfg.categories || []).join("\n"); + notesCategories = [...new Set((cfg.categories || []) + .map(normalizeConfigCategory) + .filter(Boolean))] + .sort((a, b) => a.localeCompare(b)); + renderNotesCategoryManager(); $("notes-fetch-timeout").value = cfg.fetch_timeout_seconds ?? 8; $("notes-max-chars").value = cfg.max_extracted_chars ?? 2000; $("notes-low-conf").checked = cfg.low_confidence_to_inbox !== false; + $("notes-allow-new").checked = cfg.allow_new_categories !== false; $("notes-gen-title").checked = cfg.generate_title !== false; $("notes-gen-summary").checked = cfg.generate_summary !== false; } +function normalizeConfigCategory(value) { + const parts = String(value || "") + .trim() + .replace(/\\/g, "/") + .replace(/^\/+|\/+$/g, "") + .split("/"); + if (parts.length < 1 || parts.length > 2) return ""; + const normalized = parts.map((part) => part + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) + .replace(/-+$/g, "")); + if (normalized.some((part) => !part)) return ""; + const category = normalized.join("/"); + return category === "inbox" || category.length > 65 ? "" : category; +} + +function renderNotesCategoryManager() { + const list = $("notes-categories"); + list.replaceChildren(); + for (const category of notesCategories) { + const row = document.createElement("div"); + row.className = "config-category"; + row.setAttribute("role", "listitem"); + const label = document.createElement("span"); + label.textContent = category; + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "config-category-remove"; + remove.textContent = "×"; + remove.setAttribute("aria-label", `Remove ${category}`); + remove.addEventListener("click", () => { + notesCategories = notesCategories.filter((item) => item !== category); + renderNotesCategoryManager(); + }); + row.append(label, remove); + list.append(row); + } +} + +function addNotesCategory() { + const input = $("notes-category-new"); + const category = normalizeConfigCategory(input.value); + if (!category) { + setStatus( + "config-status", + "⚠ Use one safe folder or parent/child pair, such as research or work/technical.", + false, + ); + return; + } + if (!notesCategories.includes(category)) notesCategories.push(category); + notesCategories.sort((a, b) => a.localeCompare(b)); + input.value = ""; + renderNotesCategoryManager(); + setStatus("config-status", ""); +} + function notesConfigPatch() { - const categories = $("notes-categories").value - .split("\n") - .map((line) => line.trim().replace(/^\/+|\/+$/g, "")) - .filter(Boolean); return { vault_dir: $("notes-vault").value.trim(), - categories, + categories: [...notesCategories], fetch_timeout_seconds: Number($("notes-fetch-timeout").value) || 8, max_extracted_chars: Number($("notes-max-chars").value) || 2000, low_confidence_to_inbox: $("notes-low-conf").checked, + allow_new_categories: $("notes-allow-new").checked, generate_title: $("notes-gen-title").checked, generate_summary: $("notes-gen-summary").checked, }; @@ -2691,6 +2792,7 @@ document.addEventListener("DOMContentLoaded", () => { $("notes-empty-new").addEventListener("click", () => openNewNote()); $("ne-close").addEventListener("click", closeNoteEditor); $("ne-save").addEventListener("click", saveNoteEditor); + $("ne-organize").addEventListener("click", organizeCurrentNote); $("ne-archive").addEventListener("click", archiveCurrentNote); $("ne-trash").addEventListener("click", trashCurrentNote); $("ne-restore").addEventListener("click", restoreCurrentNote); @@ -2728,6 +2830,13 @@ document.addEventListener("DOMContentLoaded", () => { $("mtg-week-gen").addEventListener("click", generateWeekSummary); $("config-save").addEventListener("click", saveConfig); $("config-revert").addEventListener("click", loadConfig); + $("notes-category-add").addEventListener("click", addNotesCategory); + $("notes-category-new").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + addNotesCategory(); + } + }); $("cm-select").addEventListener("change", fillCustomModeForm); $("cm-save").addEventListener("click", saveCustomMode); $("cm-delete").addEventListener("click", deleteCustomMode); diff --git a/scripts/ui/web/index.html b/scripts/ui/web/index.html index 8e15fff..6879012 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -291,6 +291,7 @@

New note

+ @@ -390,8 +391,14 @@

Storage

Organization

- - +
+
+ + +
+

Categories are kept sorted. Use one folder or a parent/child pair.

Link extraction & enrichment

@@ -402,6 +409,7 @@

Link extraction & enrichment

+
diff --git a/scripts/ui/web/styles.css b/scripts/ui/web/styles.css index d3987f4..6007d79 100644 --- a/scripts/ui/web/styles.css +++ b/scripts/ui/web/styles.css @@ -1181,6 +1181,50 @@ footer { padding: 14px 24px 22px; } gap: 6px; margin-top: 8px; } +.config-category-list { + display: flex; + flex-wrap: wrap; + gap: 7px; + min-height: 38px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 9px; + background: color-mix(in srgb, var(--bg) 55%, var(--surface)); +} +.config-category { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 6px 5px 9px; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); + border-radius: 999px; + background: var(--accent-soft); + color: var(--text); + font-size: 12px; +} +.config-category-remove { + display: grid; + width: 20px; + height: 20px; + padding: 0; + place-items: center; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.config-category-remove:hover, +.config-category-remove:focus-visible { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} +.config-category-add { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; + margin-top: 8px; +} .notes-ai-summary { margin: 7px 0 0; padding: 9px 10px; diff --git a/setup/defaults/grammar_hotkey.config.example.json b/setup/defaults/grammar_hotkey.config.example.json index eeb97b5..941a7df 100644 --- a/setup/defaults/grammar_hotkey.config.example.json +++ b/setup/defaults/grammar_hotkey.config.example.json @@ -94,6 +94,7 @@ "fetch_timeout_seconds": 8, "max_extracted_chars": 2000, "low_confidence_to_inbox": true, + "allow_new_categories": true, "generate_title": true, "generate_summary": true }, diff --git a/setup/defaults/grammar_hotkey.config.json b/setup/defaults/grammar_hotkey.config.json index 23ed101..650a5b7 100644 --- a/setup/defaults/grammar_hotkey.config.json +++ b/setup/defaults/grammar_hotkey.config.json @@ -98,6 +98,7 @@ "fetch_timeout_seconds": 8, "max_extracted_chars": 2000, "low_confidence_to_inbox": true, + "allow_new_categories": true, "generate_title": true, "generate_summary": true }, diff --git a/tests/test_config_seeds.py b/tests/test_config_seeds.py index 144a319..47357dd 100644 --- a/tests/test_config_seeds.py +++ b/tests/test_config_seeds.py @@ -57,8 +57,7 @@ def test_shipped_seed_keys_do_not_silently_drift_from_schema(): f"seed vs schema drift changed (missing from seed): {sorted(missing_from_seed)}" ) # In seed, not top-level in schema: runtime/user-managed optional blocks - # (chat threads config, per-hotkey overrides, notes vault config) the app - # reads directly; DEFAULT_CONFIG doesn't declare them. - assert extra_in_seed == {"chat", "hotkeys", "notes"}, ( + # (chat threads config and per-hotkey overrides) the app reads directly. + assert extra_in_seed == {"chat", "hotkeys"}, ( f"seed vs schema drift changed (extra in seed): {sorted(extra_in_seed)}" ) diff --git a/tests/test_ffp_config.py b/tests/test_ffp_config.py index e5c40d5..e53dfb2 100644 --- a/tests/test_ffp_config.py +++ b/tests/test_ffp_config.py @@ -280,6 +280,40 @@ def test_default_config_has_prompt_builder_defaults(): assert ffp_config.DEFAULT_CONFIG["prompt_builder"] == ffp_prompt_builder.DEFAULT_PROMPT_BUILDER_CONFIG assert ffp_config.DEFAULT_CONFIG["server"]["warm_on_start"] is True assert ffp_config.DEFAULT_CONFIG["server"]["keep_warm_minutes"] == 15 + assert ffp_config.DEFAULT_CONFIG["notes"]["allow_new_categories"] is True + + +def test_filter_config_patch_accepts_guarded_note_category_setting(): + filtered = ffp_config.filter_config_patch({ + "notes": { + "categories": ["research", "work/technical"], + "allow_new_categories": False, + "unknown": "drop me", + }, + }) + + assert filtered == { + "notes": { + "categories": ["research", "work/technical"], + "allow_new_categories": False, + }, + } + + +def test_update_config_load_mutate_write_is_atomic(tmp_path): + path = tmp_path / "config.json" + ffp_config.save_config(path, {"notes": {"categories": ["research"]}}) + + updated = ffp_config.update_config( + path, + lambda cfg: cfg["notes"]["categories"].append("ideas"), + ) + + assert updated["notes"]["categories"] == ["research", "ideas"] + assert ffp_config.load_config(path)["notes"]["categories"] == [ + "research", + "ideas", + ] def test_filter_config_patch_clamps_keep_warm_settings(): diff --git a/tests/test_ffp_daemon.py b/tests/test_ffp_daemon.py index f453051..1051c1e 100644 --- a/tests/test_ffp_daemon.py +++ b/tests/test_ffp_daemon.py @@ -77,12 +77,13 @@ def test_actions_count_and_expected_names(daemon_module): # meeting_ask -> 69; meeting_overview -> 70; meeting_actions_list / # meeting_action_set_status / meeting_week_summary -> 73; meeting_redigest # (strict re-run of a digest) -> 74; prompt_builder_preview -> 75; - # Notes v2 query/CRUD/Trash/board/staging adds 10 -> 85. - assert len(daemon_module.ACTIONS) == 85 + # Notes v2 query/CRUD/Trash/board/staging adds 10 -> 85; + # local-model note organization adds one -> 86. + assert len(daemon_module.ACTIONS) == 86 for a in ("chat_threads_list", "chat_thread_get", "chat_send", "chat_thread_delete", "chat_stage_selection", "chat_take_staged", "note_get", "note_move", "note_delete", "notes_query", - "note_create", "note_update", "note_archive", "note_trash", + "note_create", "note_update", "note_organize", "note_archive", "note_trash", "note_restore", "notes_board_get", "notes_board_save", "note_stage_capture", "note_take_staged", "notify_gate", "notifications_log", @@ -623,6 +624,7 @@ def test_post_config_snapshot_returns_flat_dashboard_fields(daemon_server): } notes = payload["result"]["notes"] assert isinstance(notes.get("categories"), list) + assert notes["allow_new_categories"] is True assert payload["result"]["server"]["warm_on_start"] is True assert payload["result"]["server"]["keep_warm_minutes"] == 15 assert set(payload["result"]["llm"]) >= {"provider", "base_url", "model", "configured_provider"} diff --git a/tests/test_notes_capture.py b/tests/test_notes_capture.py index 1dd087b..fd93e9e 100644 --- a/tests/test_notes_capture.py +++ b/tests/test_notes_capture.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json import re +import ffp_config import notes import pytest @@ -23,6 +25,17 @@ def test_safe_category_accepts_and_normalizes(raw, expected): assert notes._safe_category(raw) == expected +@pytest.mark.parametrize(("raw", "expected"), [ + ("Learning / Python", "learning/python"), + ("Project Ideas", "project-ideas"), + ("one/two/three", ""), + ("../escape", ""), + ("inbox", ""), +]) +def test_model_category_normalization_is_safe_and_bounded(raw, expected): + assert notes._normalize_model_category(raw) == expected + + # ---------- _parse_categorize_json (robust LLM-output parsing) --------------- def test_parse_categorize_json_plain_object(): @@ -44,6 +57,103 @@ def test_parse_categorize_json_returns_none_on_garbage(raw): assert notes._parse_categorize_json(raw) is None +def _categorize_result(monkeypatch, payload, *, allow_new=True): + registered = [] + monkeypatch.setattr(notes, "_notes_cfg", lambda: { + "categories": ["ideas", "research"], + "allow_new_categories": allow_new, + "low_confidence_to_inbox": True, + }) + monkeypatch.setattr( + notes, + "_register_model_category", + lambda category: registered.append(category) or True, + ) + monkeypatch.setattr( + notes.grammar_fix, + "_call_flm_api", + lambda *args, **kwargs: (json.dumps(payload), "local-model"), + ) + return notes._llm_categorize("some note", "", "", "", ""), registered + + +def test_known_category_is_selected_without_creating_one(monkeypatch): + result, registered = _categorize_result(monkeypatch, { + "category": "research", + "is_new": False, + "confidence": "high", + "title": "Research note", + "summary": "Summary", + }) + + assert result["category"] == "research" + assert result["created_category"] is False + assert registered == [] + + +def test_high_confidence_explicit_new_category_is_registered(monkeypatch): + result, registered = _categorize_result(monkeypatch, { + "category": "Learning / Python", + "is_new": True, + "confidence": "high", + "title": "Python note", + "summary": "Summary", + }) + + assert result["category"] == "learning/python" + assert result["suggested_category"] == "learning/python" + assert result["created_category"] is True + assert registered == ["learning/python"] + + +@pytest.mark.parametrize(("allow_new", "is_new", "confidence"), [ + (False, True, "high"), + (True, False, "high"), + (True, True, "medium"), + (True, True, "low"), +]) +def test_unapproved_new_category_stays_in_inbox( + monkeypatch, + allow_new, + is_new, + confidence, +): + result, registered = _categorize_result( + monkeypatch, + { + "category": "Learning / Python", + "is_new": is_new, + "confidence": confidence, + "title": "Python note", + "summary": "Summary", + }, + allow_new=allow_new, + ) + + assert result["category"] == "inbox" + assert result["suggested_category"] == "learning/python" + assert result["created_category"] is False + assert registered == [] + + +def test_register_model_category_atomically_dedupes_and_sorts(tmp_path, monkeypatch): + config_path = tmp_path / "config.json" + ffp_config.save_config(config_path, { + "notes": {"categories": ["research", "Ideas", "research"]}, + }) + monkeypatch.setattr(notes.grammar_fix, "CONFIG_PATH", config_path) + monkeypatch.setattr(notes.grammar_fix, "refresh_runtime_config", lambda: None) + + assert notes._register_model_category("learning/python") is True + + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["notes"]["categories"] == [ + "Ideas", + "learning/python", + "research", + ] + + # ---------- HTML extraction (stdlib parser path) ----------------------------- def test_text_extractor_strips_scripts_and_keeps_title(): diff --git a/tests/test_notes_config_location.py b/tests/test_notes_config_location.py index b12346c..9faad1b 100644 --- a/tests/test_notes_config_location.py +++ b/tests/test_notes_config_location.py @@ -10,6 +10,7 @@ "notes-fetch-timeout", "notes-max-chars", "notes-low-conf", + "notes-allow-new", "notes-gen-title", "notes-gen-summary", } @@ -34,3 +35,15 @@ def test_notes_settings_use_config_single_save_flow(): assert "notes: notesPatch" in app assert "function saveNotes()" not in app assert '"notes-save"' not in app + + +def test_category_manager_is_compact_sorted_and_model_creation_is_opt_in(): + html = (WEB / "index.html").read_text(encoding="utf-8") + app = (WEB / "app.js").read_text(encoding="utf-8") + + assert 'id="notes-category-new"' in html + assert 'id="notes-category-add"' in html + assert '