From 2f9ae42287011cc5c4378a106fbb20cf1f11c654 Mon Sep 17 00:00:00 2001 From: Colin Devroe Date: Mon, 15 Jun 2026 08:40:26 -0400 Subject: [PATCH 01/10] Added a way to create a label on the fly. New start date on cards. New due in X days --- AGENTS.md | 4 +- CODEX.md | 14 +- MCP_README.md | 15 +- app/board/boardLabels.js | 234 +++++++++++++++++++++-- app/board/boardViews.js | 119 +++++++++++- app/board/plannerView.js | 23 +-- app/board/tableView.js | 64 ++++++- app/cards/createCardElement.js | 99 +++++++++- app/cards/processAddNewCard.js | 8 +- app/init.js | 10 +- app/listeners/window.js | 47 +++++ app/lists/listActionsPopover.js | 12 ++ app/modals/toggleEditCardModal.js | 138 ++++++++++--- app/utilities/taskList.js | 112 +++++++++-- docs/README.md | 2 +- docs/codex/FILE_STRUCTURE.md | 18 +- docs/codex/PROJECT_CONTEXT.md | 47 ++--- docs/signboard-cli.md | 29 ++- docs/using-signboard.md | 36 ++-- index.html | 3 + lib/cardFrontmatter.js | 42 +++- lib/cliApp.js | 24 ++- lib/cliBoard.js | 40 +++- lib/importers/obsidian.js | 47 +++++ lib/importers/shared.js | 1 + lib/importers/trello.js | 1 + lib/mcpServer.js | 52 ++++- lib/obsidianIntegration.js | 3 + lib/taskList.js | 96 ++++++++-- main.js | 5 + preload.js | 2 +- readme.md | 6 +- scripts/test-board-views.js | 60 +++++- scripts/test-cli.js | 24 ++- scripts/test-frontmatter.js | 8 +- scripts/test-import-obsidian.js | 3 +- scripts/test-import-trello.js | 1 + scripts/test-mcp-server.js | 47 ++++- scripts/test-task-list-parser.js | 41 +++- static/styles.css | 81 +++++++- tests/playwright/signboard-smoke.spec.js | 45 ++++- 41 files changed, 1454 insertions(+), 209 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e72603a..b85de1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,11 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Obsidian integration writes flat Obsidian-friendly card properties, detects containing vaults by walking upward for `.obsidian`, auto-creates/updates managed `Signboard Board.base` files without overwriting user-customized Bases, supports metadata-only linked Obsidian notes named `Linked Signboard Note.md` from the card editor only for cards inside detected vaults, shows the Obsidian-vault-required info modal for linked-note/Base actions outside a vault, and resolves `signboard://open-card` links only through trusted board roots. `signboard://open-board?path=...` opens validated vault-contained board folders after confirmation. The optional Obsidian companion plugin lives in `obsidian-plugin/` and can create/open Signboard boards from Obsidian. Linked objects use structured `linked_objects` frontmatter for Obsidian notes, local files/folders, web URLs, app deep links, and Signboard links; local files can be linked by picker or by dragging files onto the card editor, and Kanban/Table linked-object counts use `app/utilities/linkedObjects.js`. Raw web URLs in the card body stay as Markdown text but are visually marked in the OverType preview and opened through `window.electronAPI.openExternal` from the inline open button or Cmd/Ctrl-click. Keep `lib/obsidianIntegration.js`, `main.js`, preload, renderer menus/views, plugin helpers, and docs aligned. - The sponsorship modal opens from the Board menu "Sponsor" action, About modal action, and fixed bottom-right "Sponsor" pill; the pill hides on compact windows to avoid covering board lists. - Quick Add card creation supports board/list selection across open boards, an optional app-level global shortcut while Signboard is running, and `Shift + Enter` to create, immediately open, and focus the notes field on the new card. +- Card `start` frontmatter and task `(start: YYYY-MM-DD)` / `(scheduled: YYYY-MM-DD)` markers feed Kanban, Table, Planner, CLI, MCP, and importers alongside due dates; completed checklist date markers and completed-list cards stay non-actionable by default. +- Card label popovers can create board labels inline, and Add Card/Quick Add can preselect active-board labels before creating a card. - External Published Calendar is opt-in in App Settings, served only on `127.0.0.1`, and board inclusion is controlled in each board's Workflow settings. - Completed-list workflow settings live in board settings; Planner, board date filters, and due notifications treat completed-list cards as non-actionable by default while preserving due dates. -- Completed task-list item due markers should not keep cards in Planner/date-filter views; only incomplete task due markers should place cards there. +- Completed task-list item date markers should not keep cards in Planner/date-filter views; only incomplete task start/due markers should place cards there. - Keep agent-facing docs up to date: [CODEX.md](./CODEX.md), [AGENTS.md](./AGENTS.md), [docs/codex/PROJECT_CONTEXT.md](./docs/codex/PROJECT_CONTEXT.md), and [docs/codex/FILE_STRUCTURE.md](./docs/codex/FILE_STRUCTURE.md). - Keep release-facing docs up to date when user behavior or CLI behavior changes: [docs/README.md](./docs/README.md), [docs/using-signboard.md](./docs/using-signboard.md), [docs/signboard-cli.md](./docs/signboard-cli.md), [readme.md](./readme.md), and [MCP_README.md](./MCP_README.md) when relevant. diff --git a/CODEX.md b/CODEX.md index 1d6104e..4f5ef08 100644 --- a/CODEX.md +++ b/CODEX.md @@ -27,7 +27,7 @@ Start here before opening source files. - Active-card adjacent-list moves use the main-process `moveCardToTop` IPC path backed by `lib/cardOrdering.js`, so renderer shortcuts do not perform multi-step filesystem renames directly. - Board rendering supports Kanban by default plus a board-scoped Table view for scanning cards; Calendar, This Week, Day, and Agenda dated workflows live in Planner. - Planner is managed in `app/board/plannerView.js`; it appears as a left-edge overlay only when at least one board tab is open, defaults to all open boards, has a quick current-board scope toggle, and owns Calendar, This Week, Day, and Agenda views. -- Planner search/filtering is separate from board search: it searches card title/body plus board/list source text, filters by `Today` / `Overdue`, completed-card visibility, and selected open boards, and exposes label filtering only when scoped to the active board. +- Planner search/filtering is separate from board search: it searches card title/body plus board/list source text, filters by `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days`, completed-card visibility, and selected open boards, and exposes label filtering only when scoped to the active board. - App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut and External Published Calendar settings are stored only in app settings. - Board-level workflow settings in `board-settings.md` auto-detect completed lists by name (`Done`, `Completed`, `Complete`, `Closed`, `Finished`, `Resolved`, `Shipped`) with manual overrides; Planner, board date filters, and due notifications hide completed-list cards by default while preserving due dates. - Planner uses the default Signboard palette for the active light/dark mode instead of inheriting the active board color scheme; Planner cards show `Board ยท List` source context with source pills tinted from each card's board color scheme. @@ -36,17 +36,17 @@ Start here before opening source files. - Shared card drag options and tilt behavior live in `app/utilities/cardDragTilt.js`; the Sortable ghost is styled in `static/styles.css` as an empty drop slot rather than a translucent duplicate card. - Card and list rendering should preserve native keyboard controls and screen-reader structure: cards expose a button title inside each list item, lists expose labelled sections, and editable list/card titles use subtle keyboard-only focus affordances rather than persistent editor outlines. - Board, Planner, and Archive search support keyboard result navigation: `Enter` / arrows from the search field move into visible results, arrows move between results, `Esc` returns to search, and `Esc` from search clears the query where applicable. Board tabs, list actions, label/filter popovers, and Settings sections also support arrow-key movement plus `Home` / `End`; popovers restore focus to their opener on `Esc`, and focused board tabs close with `Delete` / `Backspace`. -- The header filter popover is owned by `app/board/boardLabels.js`; it supports temporary `Today` / `Overdue` date filters plus multi-select label filters for Kanban and Table. -- Date filters and Planner date views intentionally ignore completed task-level due markers and completed-list cards by default; card-level due dates still count for actionable lists, and incomplete task due markers still drive matches in Kanban, Table, and Planner. +- The header filter popover is owned by `app/board/boardLabels.js`; it supports temporary `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days` date filters plus multi-select label filters for Kanban and Table. Card-label popovers can create board labels inline, and Add Card/Quick Add can assign existing or newly created active-board labels before the card file is written. +- Date filters and Planner date views intentionally ignore completed task-level date markers and completed-list cards by default; card-level `start`/`due` dates still count for actionable lists, and incomplete task `start`/`due` markers still drive matches in Kanban, Table, and Planner. - The filter toolbar button is icon-only; when filters are active it gets an accent-tinted active state and exposes the active summary through tooltip/ARIA text rather than visible label text. - Keyboard shortcut handling is centralized in `app/listeners/window.js`; the helper modal is rendered in `index.html` as `#modalKeyboardShortcuts`, renderer shortcuts cover Quick Add card creation (`Cmd/Ctrl + N`, with board/list selection across open boards), quick board switching (`Cmd/Ctrl + K`), board view switching (`Cmd/Ctrl + 1` closes Planner back to Kanban, `Cmd/Ctrl + Option/Alt + 1` closes Planner back to Table), Planner toggle/view switching (`Cmd/Ctrl + Shift + P`, `Cmd/Ctrl + 2/3/4/5` opens the matching Planner date view for all open boards, `Cmd/Ctrl + Option/Alt + 2/3/4/5` opens the matching Planner date view scoped to the current board), Settings (`Cmd/Ctrl + ,`), color-scheme cycling, archive browsing, and active-card move/archive actions. Workspace-level shortcuts close the active card editor before changing context, while editor-scoped move/archive shortcuts keep acting on the open card. Native menu accelerators cover board switching, board view switching, Settings, and theme toggling. The optional app-level Quick Add global shortcut is registered in `main.js` with Electron `globalShortcut` and sends the same renderer Quick Add command while Signboard is running. - Shortcut label formatting is shared from `app/board/boardLabels.js`, so the keyboard helper modal, board/view menus, and list-action popovers all stay OS-aware and in sync. -- New cards created through the desktop app, CLI, MCP, and importers now receive `createdAt` plus a compact `activity` trail (`created`, `moved-list`, `archived`, `restored`); active archive state lives in card frontmatter under `archive`, and archived lists persist lightweight sidecar metadata in `.signboard-archive.json`. The desktop Quick Add modal supports board/list selection across open boards and `Shift + Enter` to create, immediately open, and focus the notes field on the new card. +- New cards created through the desktop app, CLI, MCP, and importers now receive `createdAt` plus a compact `activity` trail (`created`, `moved-list`, `archived`, `restored`); active archive state lives in card frontmatter under `archive`, and archived lists persist lightweight sidecar metadata in `.signboard-archive.json`. The desktop Quick Add modal supports board/list selection across open boards, active-board label assignment, and `Shift + Enter` to create, immediately open, and focus the notes field on the new card. - Desktop `readCard`, CLI JSON card output, and MCP card tool responses expose normalized card timestamps under `timestamps.createdAt` and `timestamps.updatedAt`; `createdAt` prefers card frontmatter/activity and falls back to filesystem data for legacy cards, while `updatedAt` comes from filesystem modification time. Keep the card editor timestamp display, Table view age columns/sort options, CLI age sorts, and MCP responses aligned with `lib/cardTimestamps.js` and `app/utilities/cardTimestamps.js`. -- Task checklist parsing + counters + task due-date helpers live in `app/utilities/taskList.js` and feed Kanban/Table/Planner card badges. +- Task checklist parsing + counters + task date helpers live in `app/utilities/taskList.js` and feed Kanban/Table/Planner card badges. Task date markers support `(start: YYYY-MM-DD)`, `(scheduled: YYYY-MM-DD)`, and `(due: YYYY-MM-DD)` at the start of checklist content. - Due notification aggregation/formatting (including task-due item snippets) lives in `app/utilities/dueNotifications.js` and is consumed by `app/init.js`. - External Published Calendar feed collection/ICS generation lives in `lib/externalPublishedCalendar.js`; keep it aligned with Planner/date-filter completed-list and checked-task behavior. -- Task-line due-date controls in the editor are positioned from measured textarea line-start coordinates (not raw line index math) to stay aligned with wrapped content. +- Task-line start/due date controls in the editor are positioned from measured textarea line-start coordinates (not raw line index math) to stay aligned with wrapped content. - In dev/unpackaged builds, `Help` includes updater preview dialogs so update UI can be tested without publishing a release. - Release assets for updater compatibility are validated by `scripts/verify-release-assets.js` (`npm run release:verify`). - Standard public releases now promote a curated download set: macOS universal, one Windows installer, and Linux `x64`/`ARM64` `AppImage` + `deb` packages. Use `docs/release-template.md` for the GitHub release body. @@ -69,7 +69,7 @@ Start here before opening source files. - External import pickers are tokenized in `main.js` and surfaced through `window.chooser.pickImportSources(...)`; renderer code never reads arbitrary external files directly. - Trello, Obsidian, and Tasks.md importer coverage lives in `scripts/test-import-trello.js`, `scripts/test-import-obsidian.js`, and `scripts/test-import-tasksmd.js`. - The terminal CLI exposes `boards create` for default board scaffolding, a dedicated `archive` namespace (`archive cards`, `archive lists`, `archive read`, `archive restore-card`, `archive restore-list`), and path-based import commands (`signboard import trello --file ...`, `signboard import obsidian --source ...`, `signboard import tasksmd --source ...`); MCP advertises matching board-create/archive/import tools while still accepting dotted `signboard.*` legacy aliases. -- CLI card writes include template duplication (`cards duplicate`, `cards create --from-card`), section-aware body edits, timestamped note insertion, explicit label clearing, and `--dry-run` previews for card write commands. +- CLI card writes include `--start`, template duplication (`cards duplicate`, `cards create --from-card`), section-aware body edits, timestamped note insertion, explicit label clearing, and `--dry-run` previews for card write commands. - CLI due filtering in `lib/cliBoard.js` now defaults `--due overdue` to open task items only, with `--task-status any` available when callers want completed task due markers included. - `AGENTS.md` is the cross-tool compatibility entrypoint and should stay aligned with this file. - Skip heavy/generated content unless explicitly needed: `node_modules/`, `dist/`, `static/vendor/`, and usually `package-lock.json`. diff --git a/MCP_README.md b/MCP_README.md index a4a2f5b..0102f7f 100644 --- a/MCP_README.md +++ b/MCP_README.md @@ -130,18 +130,24 @@ Import tools also take absolute external source paths, and those paths must reso - `card.timestamps.createdAt`: ISO timestamp for when the card was created, preferring Signboard card metadata and falling back to filesystem timestamps for older cards - `card.timestamps.updatedAt`: ISO timestamp from the card file's filesystem modification time when available - `taskSummary`: `{ total, completed, remaining }` -- `taskDueDates`: sorted unique ISO dates found in task lines (`YYYY-MM-DD`) +- `card.start`: optional card start/scheduled date (`YYYY-MM-DD`) when present +- `card.due`: optional card due date (`YYYY-MM-DD`) when present +- `taskStartDates`: sorted unique ISO start/scheduled dates found in task lines (`YYYY-MM-DD`) +- `taskDueDates`: sorted unique ISO due dates found in task lines (`YYYY-MM-DD`) Task parsing rules: - Checklist items are recognized from markdown checkbox lines (for example: `- [ ]`, `- [x]`, `- [X]`, `- [x ]`, `- [ x]`, `- [ x ]`). -- Task-level due dates are recognized only when the task content starts with: +- Task-level date markers are recognized when the task content starts with one or more of: + - `(start: YYYY-MM-DD)` + - `(scheduled: YYYY-MM-DD)` - `(due: YYYY-MM-DD)` Example: ```md -- [ ] (due: 2026-03-20) Draft announcement +- [ ] (start: 2026-03-18) (due: 2026-03-20) Draft announcement +- [ ] (scheduled: 2026-03-21) Follow up - [x ] Confirm reviewers ``` @@ -154,6 +160,7 @@ Returned metadata shape: "completed": 1, "remaining": 1 }, + "taskStartDates": ["2026-03-18", "2026-03-21"], "taskDueDates": ["2026-03-20"] } ``` @@ -206,7 +213,7 @@ If neither `SIGNBOARD_MCP_ALLOWED_ROOTS` nor desktop trusted board roots are ava - The process communicates over stdio (MCP JSON-RPC framing). - The stdio parser accepts both header-framed MCP and newline-delimited JSON-RPC payloads. - `signboard_list_board_views` reports the board-scoped Kanban and Table views; dated Calendar/This Week/Day/Agenda planning is handled by the desktop Planner overlay. -- Card reads/writes use Signboard's existing frontmatter logic (`lib/cardFrontmatter.js`). +- Card reads/writes use Signboard's existing frontmatter logic (`lib/cardFrontmatter.js`), including optional `start` and `due` fields. - `signboard_create_card` and `signboard_update_card` normalize literal `\n` / `\N` escape sequences in body input into real line breaks. - `signboard_update_card` can replace a Markdown heading section (`replaceSection` + `body`), insert text after a heading (`insertAfterHeading` + `insertText`), append a note under `## Notes` (`addNote`), and clear/add/remove labels without replacing the full body. - `dryRun: true` on card create/update/duplicate returns the planned card payload without writing a file. diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index a97f1e6..cdc9831 100644 --- a/app/board/boardLabels.js +++ b/app/board/boardLabels.js @@ -1026,6 +1026,14 @@ function hasThemeModeOverride(themeModeOverrides) { const BOARD_DATE_FILTER_NONE = ''; const BOARD_DATE_FILTER_TODAY = 'today'; const BOARD_DATE_FILTER_OVERDUE = 'overdue'; +const BOARD_DATE_FILTER_NEXT_7 = 'next:7'; +const BOARD_DATE_FILTER_NEXT_14 = 'next:14'; +const BOARD_DATE_FILTER_NEXT_30 = 'next:30'; +const BOARD_DATE_FILTER_NEXT_DAY_COUNTS = Object.freeze({ + [BOARD_DATE_FILTER_NEXT_7]: 7, + [BOARD_DATE_FILTER_NEXT_14]: 14, + [BOARD_DATE_FILTER_NEXT_30]: 30, +}); const BOARD_LABEL_SCROLL_THRESHOLD = 11; function getBoardLabelState() { @@ -1036,6 +1044,7 @@ function getBoardLabelState() { filterIds: [], activeDateFilter: BOARD_DATE_FILTER_NONE, activeCardLabelPopover: null, + cardCreationLabelIdsByContext: new Map(), colorScheme: '', themeOverrides: { light: {}, dark: {} }, themePalettes: { @@ -1330,6 +1339,10 @@ function normalizeBoardDateFilter(value) { return BOARD_DATE_FILTER_OVERDUE; } + if (Object.prototype.hasOwnProperty.call(BOARD_DATE_FILTER_NEXT_DAY_COUNTS, normalized)) { + return normalized; + } + return BOARD_DATE_FILTER_NONE; } @@ -1350,6 +1363,11 @@ function getBoardDateFilterLabel(filterValue) { return 'Overdue'; } + const nextDayCount = BOARD_DATE_FILTER_NEXT_DAY_COUNTS[filterValue]; + if (nextDayCount) { + return `Next ${nextDayCount} days`; + } + return ''; } @@ -1370,6 +1388,19 @@ function normalizeBoardFilterDueDate(value) { return parseIsoDateStringToLocalDate(normalized) ? normalized : ''; } +function addDaysToBoardFilterDate(isoDate, dayCount) { + const parsedDate = parseIsoDateStringToLocalDate(isoDate); + if (!parsedDate) { + return ''; + } + + parsedDate.setDate(parsedDate.getDate() + Number(dayCount || 0)); + const year = parsedDate.getFullYear(); + const month = String(parsedDate.getMonth() + 1).padStart(2, '0'); + const day = String(parsedDate.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + function doesBoardDateFilterMatchDueDate(dueDateValue, activeFilter = getActiveBoardDateFilter()) { const normalizedDueDate = normalizeBoardFilterDueDate(dueDateValue); if (!normalizedDueDate) { @@ -1389,6 +1420,12 @@ function doesBoardDateFilterMatchDueDate(dueDateValue, activeFilter = getActiveB return normalizedDueDate < todayIsoDate; } + const nextDayCount = BOARD_DATE_FILTER_NEXT_DAY_COUNTS[activeFilter]; + if (nextDayCount) { + const endIsoDate = addDaysToBoardFilterDate(todayIsoDate, nextDayCount); + return normalizedDueDate >= todayIsoDate && normalizedDueDate <= endIsoDate; + } + return true; } @@ -1423,7 +1460,7 @@ function getActiveBoardFilterDueDates( incompleteTaskDueDateValues = taskDueDateValues, activeFilter = getActiveBoardDateFilter(), ) { - if (activeFilter === BOARD_DATE_FILTER_TODAY || activeFilter === BOARD_DATE_FILTER_OVERDUE) { + if (activeFilter !== BOARD_DATE_FILTER_NONE) { return getCardFilterDueDates( cardDueDateValue, Array.isArray(incompleteTaskDueDateValues) ? incompleteTaskDueDateValues : taskDueDateValues, @@ -1569,6 +1606,9 @@ function renderBoardLabelFilterPopover() { popover.appendChild(createDateFilterRow(BOARD_DATE_FILTER_TODAY, 'Today', 'sun')); popover.appendChild(createDateFilterRow(BOARD_DATE_FILTER_OVERDUE, 'Overdue', 'alert-circle')); + popover.appendChild(createDateFilterRow(BOARD_DATE_FILTER_NEXT_7, 'Next 7 days', 'calendar')); + popover.appendChild(createDateFilterRow(BOARD_DATE_FILTER_NEXT_14, 'Next 14 days', 'calendar')); + popover.appendChild(createDateFilterRow(BOARD_DATE_FILTER_NEXT_30, 'Next 30 days', 'calendar')); const separator = document.createElement('div'); separator.className = 'label-popover-separator'; @@ -1811,6 +1851,69 @@ function positionCardLabelPopover(popover, anchorElement) { popover.style.left = `${Math.min(window.innerWidth - 260, Math.max(8, bounds.left - 60))}px`; } +function createCardLabelCreateForm(knownSelection, unknownLabelIds, onChange) { + const form = document.createElement('form'); + form.className = 'card-label-create-form'; + form.noValidate = true; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'card-label-create-input'; + input.placeholder = 'New label'; + input.setAttribute('aria-label', 'New label name'); + form.appendChild(input); + + const button = document.createElement('button'); + button.type = 'submit'; + button.className = 'card-label-create-button'; + button.textContent = 'Add'; + form.appendChild(button); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + event.stopPropagation(); + + const labelName = String(input.value || '').trim(); + if (!labelName) { + input.setAttribute('aria-invalid', 'true'); + input.focus(); + return; + } + + input.removeAttribute('aria-invalid'); + input.disabled = true; + button.disabled = true; + + try { + const createdLabel = addBoardLabelDefinition({ + name: labelName, + renderSettings: false, + }); + await flushBoardSettingsSave(); + if (createdLabel && createdLabel.id) { + knownSelection.add(createdLabel.id); + } + + const orderedKnownSelection = getBoardLabels() + .map((entry) => entry.id) + .filter((entryId) => knownSelection.has(entryId)); + await onChange([...orderedKnownSelection, ...unknownLabelIds]); + closeCardLabelPopover(); + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus(`Created label "${labelName}".`); + } + } catch (error) { + console.error('Unable to create label.', error); + input.disabled = false; + button.disabled = false; + input.setAttribute('aria-invalid', 'true'); + input.focus(); + } + }); + + return form; +} + function createCardLabelPopoverContent(selectedLabelIds, onChange) { const labels = getBoardLabels(); const knownLabelIds = new Set(labels.map((label) => label.id)); @@ -1822,9 +1925,8 @@ function createCardLabelPopoverContent(selectedLabelIds, onChange) { if (labels.length === 0) { const emptyState = document.createElement('p'); emptyState.className = 'label-popover-empty'; - emptyState.textContent = 'No labels yet. Add labels in Settings.'; + emptyState.textContent = 'No labels yet.'; fragment.appendChild(emptyState); - return fragment; } for (const label of labels) { @@ -1869,6 +1971,12 @@ function createCardLabelPopoverContent(selectedLabelIds, onChange) { fragment.appendChild(unknownHint); } + const separator = document.createElement('div'); + separator.className = 'label-popover-separator'; + separator.setAttribute('aria-hidden', 'true'); + fragment.appendChild(separator); + fragment.appendChild(createCardLabelCreateForm(knownSelection, unknownLabelIds, onChange)); + return fragment; } @@ -1876,6 +1984,10 @@ function toggleCardLabelSelector(anchorElement, cardPath, selectedLabelIds, onCh const state = getBoardLabelState(); const popover = state.activeCardLabelPopover; + if (typeof destroyActiveDueDatePicker === 'function') { + destroyActiveDueDatePicker(); + } + if (popover && popover.__anchorElement === anchorElement) { closeCardLabelPopover(); return; @@ -1892,6 +2004,9 @@ function toggleCardLabelSelector(anchorElement, cardPath, selectedLabelIds, onCh menu.setAttribute('role', 'group'); menu.setAttribute('aria-label', 'Card labels'); menu.setAttribute('aria-hidden', 'false'); + menu.setAttribute('data-sb-modal-layer', ''); + menu.inert = false; + menu.removeAttribute('data-sb-modal-inert'); menu.__anchorElement = anchorElement; menu.__cardPath = cardPath; @@ -1919,6 +2034,97 @@ function toggleCardLabelSelector(anchorElement, cardPath, selectedLabelIds, onCh state.activeCardLabelPopover = menu; } +function normalizeCardCreationLabelContext(contextId) { + return String(contextId || 'default').trim() || 'default'; +} + +function getCardCreationLabelIds(contextId = 'default') { + const state = getBoardLabelState(); + const key = normalizeCardCreationLabelContext(contextId); + const values = state.cardCreationLabelIdsByContext.get(key); + return Array.isArray(values) ? values.slice() : []; +} + +function getCardCreationLabelButton(contextId = 'default') { + const key = normalizeCardCreationLabelContext(contextId); + const buttonId = key === 'quick-add' ? 'quickAddCardLabelButton' : 'newCardLabelButton'; + return document.getElementById(buttonId); +} + +function renderCardCreationLabelButton(contextId = 'default') { + const button = getCardCreationLabelButton(contextId); + if (!button) { + return; + } + + const selectedLabelIds = getCardCreationLabelIds(contextId); + const labelNames = selectedLabelIds + .map((labelId) => getBoardLabelById(labelId)) + .filter(Boolean) + .map((label) => String(label.name || '').trim()) + .filter(Boolean); + const text = button.querySelector('.new-card-label-button-text'); + const summary = labelNames.length === 0 + ? 'Labels' + : (labelNames.length === 1 ? labelNames[0] : `${labelNames.length} labels`); + + if (text) { + text.textContent = summary; + } + button.classList.toggle('has-labels', labelNames.length > 0); + button.setAttribute('aria-label', labelNames.length > 0 ? `Edit labels: ${labelNames.join(', ')}` : 'Set labels'); + button.title = labelNames.length > 0 ? `Labels: ${labelNames.join(', ')}` : 'Set labels'; +} + +function setCardCreationLabelIds(contextId = 'default', labelIds = []) { + const state = getBoardLabelState(); + const key = normalizeCardCreationLabelContext(contextId); + const normalizedIds = Array.isArray(labelIds) + ? labelIds.map((labelId) => String(labelId)).filter(Boolean) + : []; + + if (normalizedIds.length > 0) { + state.cardCreationLabelIdsByContext.set(key, normalizedIds); + } else { + state.cardCreationLabelIdsByContext.delete(key); + } + + renderCardCreationLabelButton(key); +} + +function resetCardCreationLabelSelection(contextId = 'default') { + setCardCreationLabelIds(contextId, []); +} + +function getCardCreationFrontmatter(contextId = 'default') { + const labelIds = getCardCreationLabelIds(contextId); + return labelIds.length > 0 ? { labels: labelIds } : {}; +} + +function initializeCardCreationLabelButton(contextId = 'default') { + const key = normalizeCardCreationLabelContext(contextId); + const button = getCardCreationLabelButton(key); + if (!button) { + return; + } + + button.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + + toggleCardLabelSelector( + button, + '', + getCardCreationLabelIds(key), + async (nextLabelIds) => { + setCardCreationLabelIds(key, nextLabelIds); + }, + ); + }; + + renderCardCreationLabelButton(key); +} + function createBoardSettingsLabelRow(label, index) { const row = document.createElement('div'); row.className = 'board-settings-label-row'; @@ -2110,27 +2316,33 @@ function getNextBoardLabelColors() { return { ...colorPair }; } -function addBoardLabelDefinition() { +function addBoardLabelDefinition(options = {}) { + const sourceOptions = typeof options === 'string' ? { name: options } : (options && typeof options === 'object' ? options : {}); const labels = getBoardLabels(); const nextIndex = labels.length + 1; + const requestedName = String(sourceOptions.name || '').trim(); const candidateColors = getNextBoardLabelColors(); const colors = createReadableLabelColors(candidateColors.colorLight, candidateColors.colorLight); + const newLabel = { + id: generateBoardLabelId(), + name: requestedName || `Label ${nextIndex}`, + colorLight: colors.colorLight, + colorDark: colors.colorDark, + }; const nextLabels = [ ...labels.map((label) => ({ ...label })), - { - id: generateBoardLabelId(), - name: `Label ${nextIndex}`, - colorLight: colors.colorLight, - colorDark: colors.colorDark, - }, + newLabel, ]; setBoardLabels(nextLabels); - renderBoardSettingsLabels(); + if (sourceOptions.renderSettings !== false) { + renderBoardSettingsLabels(); + } renderBoardLabelFilterButton(); renderBoardLabelFilterPopover(); scheduleBoardLabelSettingsSave(); + return newLabel; } async function removeBoardLabelReferencesFromCards(labelId) { diff --git a/app/board/boardViews.js b/app/board/boardViews.js index 0a2d367..41c975a 100644 --- a/app/board/boardViews.js +++ b/app/board/boardViews.js @@ -312,12 +312,25 @@ function getOpenTaskDueDatesForEntry(cardEntry) { : []; } +function getOpenTaskStartDatesForEntry(cardEntry) { + if (cardEntry && Array.isArray(cardEntry.incompleteTaskStartDates)) { + return cardEntry.incompleteTaskStartDates; + } + + return cardEntry && Array.isArray(cardEntry.taskStartDates) + ? cardEntry.taskStartDates + : []; +} + function getTemporalDueDatesForEntry(cardEntry) { if (!cardEntry) { return []; } - return getCardFilterDueDates(cardEntry.due, getOpenTaskDueDatesForEntry(cardEntry)); + return getCardFilterDueDates( + [cardEntry.start, cardEntry.due], + [...getOpenTaskStartDatesForEntry(cardEntry), ...getOpenTaskDueDatesForEntry(cardEntry)], + ); } function getTaskItemsDueOnDate(taskItems, dueDateValue, options = {}) { @@ -336,6 +349,22 @@ function getTaskItemsDueOnDate(taskItems, dueDateValue, options = {}) { }); } +function getTaskItemsStartingOnDate(taskItems, startDateValue, options = {}) { + const normalizedStartDate = normalizeTaskStartDateValue(startDateValue); + if (!normalizedStartDate || !Array.isArray(taskItems)) { + return []; + } + + const includeCompleted = options && options.includeCompleted === true; + return taskItems.filter((taskItem) => { + if (!includeCompleted && taskItem && taskItem.isCompleted) { + return false; + } + + return normalizeTaskStartDateValue(taskItem && taskItem.start) === normalizedStartDate; + }); +} + function formatTemporalTaskTitle(taskItems) { if (!Array.isArray(taskItems) || taskItems.length === 0) { return 'Task due'; @@ -352,6 +381,10 @@ function formatTemporalTaskTitle(taskItems) { return `${firstTaskText} +${additionalCount} more`; } +function formatTemporalStartTitle(titleText) { + return `Start: ${truncateCalendarCardTitle(titleText)}`; +} + function createTemporalPlacementForDate(cardEntry, dueDateValue) { const normalizedDueDate = normalizeTaskDueDateValue(dueDateValue); if (!normalizedDueDate) { @@ -373,7 +406,31 @@ function createTemporalPlacementForDate(cardEntry, dueDateValue) { } if (normalizeTaskDueDateValue(cardEntry && cardEntry.due) !== normalizedDueDate) { - return null; + const taskItemsStartingOnDate = getTaskItemsStartingOnDate(cardEntry.taskItems, normalizedDueDate); + if (taskItemsStartingOnDate.length > 0) { + return { + ...cardEntry, + temporalDisplayTitle: formatTemporalStartTitle(formatTemporalTaskTitle(taskItemsStartingOnDate)), + temporalDisplaySubtitle: cardEntry.title, + temporalReason: 'task-start', + temporalTaskCount: taskItemsStartingOnDate.length, + temporalTaskLineIndexes: taskItemsStartingOnDate + .map((taskItem) => Number(taskItem && taskItem.lineIndex)) + .filter((lineIndex) => Number.isInteger(lineIndex) && lineIndex >= 0), + }; + } + + if (normalizeTaskStartDateValue(cardEntry && cardEntry.start) !== normalizedDueDate) { + return null; + } + + return { + ...cardEntry, + temporalDisplayTitle: formatTemporalStartTitle(cardEntry.title), + temporalDisplaySubtitle: '', + temporalReason: 'card-start', + temporalTaskCount: 0, + }; } return { @@ -409,6 +466,19 @@ function getTaskLineIndexesDueOnDateFromBody(bodyValue, dueDateValue) { .filter((lineIndex) => Number.isInteger(lineIndex) && lineIndex >= 0); } +function getTaskLineIndexesStartingOnDateFromBody(bodyValue, startDateValue) { + const normalizedStartDate = normalizeTaskStartDateValue(startDateValue); + if (!normalizedStartDate) { + return []; + } + + return parseTaskListItems(bodyValue) + .filter((taskItem) => taskItem && !taskItem.isCompleted) + .filter((taskItem) => normalizeTaskStartDateValue(taskItem.start) === normalizedStartDate) + .map((taskItem) => Number(taskItem.lineIndex)) + .filter((lineIndex) => Number.isInteger(lineIndex) && lineIndex >= 0); +} + async function moveTemporalTaskDueDate(cardPath, sourceDate, targetDate, taskLineIndexes) { const card = await window.board.readCard(cardPath); const body = String(card && card.body ? card.body : ''); @@ -434,6 +504,31 @@ async function moveTemporalTaskDueDate(cardPath, sourceDate, targetDate, taskLin return true; } +async function moveTemporalTaskStartDate(cardPath, sourceDate, targetDate, taskLineIndexes) { + const card = await window.board.readCard(cardPath); + const body = String(card && card.body ? card.body : ''); + const frontmatter = card && card.frontmatter && typeof card.frontmatter === 'object' + ? card.frontmatter + : {}; + const lineIndexes = Array.isArray(taskLineIndexes) && taskLineIndexes.length > 0 + ? taskLineIndexes + : getTaskLineIndexesStartingOnDateFromBody(body, sourceDate); + if (lineIndexes.length === 0) { + return false; + } + + let nextBody = body; + for (const lineIndex of lineIndexes) { + nextBody = setTaskListItemStartDateByLineIndex(nextBody, lineIndex, targetDate); + } + + await window.board.writeCard(cardPath, { + frontmatter, + body: nextBody, + }); + return true; +} + async function moveTemporalCardDueDate(cardPath, sourceDate, targetDate, draggedCard) { const temporalReason = draggedCard instanceof HTMLElement ? String(draggedCard.dataset.temporalReason || '').trim() @@ -451,6 +546,23 @@ async function moveTemporalCardDueDate(cardPath, sourceDate, targetDate, dragged } } + if (temporalReason === 'task-start') { + const updatedTask = await moveTemporalTaskStartDate( + cardPath, + sourceDate, + targetDate, + getTemporalTaskLineIndexesFromElement(draggedCard), + ); + if (updatedTask) { + return; + } + } + + if (temporalReason === 'card-start') { + await window.board.updateFrontmatter(cardPath, { start: targetDate }); + return; + } + await window.board.updateFrontmatter(cardPath, { due: targetDate }); } @@ -512,6 +624,7 @@ async function collectCardsForCalendar(boardRoot, lists, options = {}) { listDisplayName, isCompletedList: Boolean(isCompletedList), title: truncateCalendarCardTitle(frontmatter.title), + start: String(frontmatter.start || '').trim(), due: String(frontmatter.due || '').trim(), labels: Array.isArray(frontmatter.labels) ? frontmatter.labels.map((labelId) => String(labelId)) @@ -519,6 +632,8 @@ async function collectCardsForCalendar(boardRoot, lists, options = {}) { body, taskSummary: getTaskListSummary(body), taskItems, + taskStartDates: typeof getTaskListStartDates === 'function' ? getTaskListStartDates(body) : [], + incompleteTaskStartDates: typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(body) : [], taskDueDates: getTaskListDueDates(body), incompleteTaskDueDates: getIncompleteTaskListDueDates(body), }; diff --git a/app/board/plannerView.js b/app/board/plannerView.js index dd888e0..29d80ad 100644 --- a/app/board/plannerView.js +++ b/app/board/plannerView.js @@ -158,12 +158,9 @@ function getPlannerActiveView() { } function getPlannerActiveDateFilter() { - const normalized = String(getPlannerState().dateFilter || '').trim(); - if (normalized === BOARD_DATE_FILTER_TODAY || normalized === BOARD_DATE_FILTER_OVERDUE) { - return normalized; - } - - return BOARD_DATE_FILTER_NONE; + return typeof normalizeBoardDateFilter === 'function' + ? normalizeBoardDateFilter(getPlannerState().dateFilter) + : BOARD_DATE_FILTER_NONE; } function getPlannerShowCompletedCards() { @@ -596,10 +593,9 @@ function handlePlannerSearchResultKeydown(event) { } function setPlannerDateFilter(filterValue) { - const normalized = String(filterValue || '').trim(); const state = getPlannerState(); - state.dateFilter = (normalized === BOARD_DATE_FILTER_TODAY || normalized === BOARD_DATE_FILTER_OVERDUE) - ? normalized + state.dateFilter = typeof normalizeBoardDateFilter === 'function' + ? normalizeBoardDateFilter(filterValue) : BOARD_DATE_FILTER_NONE; renderPlannerViewControls(); renderPlannerFilterPopover(); @@ -1536,10 +1532,8 @@ function renderPlannerViewControls() { if (filterButton) { const activeFilterParts = []; const activeDateFilter = getPlannerActiveDateFilter(); - if (activeDateFilter === BOARD_DATE_FILTER_TODAY) { - activeFilterParts.push('Today'); - } else if (activeDateFilter === BOARD_DATE_FILTER_OVERDUE) { - activeFilterParts.push('Overdue'); + if (activeDateFilter) { + activeFilterParts.push(getBoardDateFilterLabel(activeDateFilter)); } if (selectedBoards.length !== openBoards.length) { @@ -1591,6 +1585,9 @@ function renderPlannerFilterPopover() { { value: BOARD_DATE_FILTER_NONE, label: 'All dated cards' }, { value: BOARD_DATE_FILTER_TODAY, label: 'Today' }, { value: BOARD_DATE_FILTER_OVERDUE, label: 'Overdue' }, + { value: BOARD_DATE_FILTER_NEXT_7, label: 'Next 7 days' }, + { value: BOARD_DATE_FILTER_NEXT_14, label: 'Next 14 days' }, + { value: BOARD_DATE_FILTER_NEXT_30, label: 'Next 30 days' }, ]; for (const option of dateOptions) { diff --git a/app/board/tableView.js b/app/board/tableView.js index d9ace42..c654e14 100644 --- a/app/board/tableView.js +++ b/app/board/tableView.js @@ -1,4 +1,5 @@ const BOARD_TABLE_COLUMNS = Object.freeze([ + { id: 'start', label: 'Start' }, { id: 'due', label: 'Due' }, { id: 'updated', label: 'Updated' }, { id: 'created', label: 'Created' }, @@ -76,15 +77,22 @@ function getBoardTableListOptions(listEntries) { function boardTableEntryMatchesFilters(entry) { const labels = Array.isArray(entry.labels) ? entry.labels : []; + const taskStartDates = Array.isArray(entry.taskStartDates) ? entry.taskStartDates : []; + const incompleteTaskStartDates = Array.isArray(entry.incompleteTaskStartDates) + ? entry.incompleteTaskStartDates + : taskStartDates; const taskDueDates = Array.isArray(entry.taskDueDates) ? entry.taskDueDates : []; const incompleteTaskDueDates = Array.isArray(entry.incompleteTaskDueDates) ? entry.incompleteTaskDueDates : taskDueDates; - const cardDueDates = getCardFilterDueDates(entry.due, taskDueDates); + const cardDueDates = getCardFilterDueDates( + [entry.start, entry.due], + [...taskStartDates, ...taskDueDates], + ); const activeFilterDueDates = getActiveBoardFilterDueDates( - entry.due, - taskDueDates, - incompleteTaskDueDates, + [entry.start, entry.due], + [...taskStartDates, ...taskDueDates], + [...incompleteTaskStartDates, ...incompleteTaskDueDates], ); return cardMatchesBoardLabelFilter(labels, cardDueDates, activeFilterDueDates, { @@ -114,12 +122,15 @@ async function collectBoardTableCards(boardRoot, listsWithCards) { listDisplayName: listEntry.listDisplayName, isCompletedList: Boolean(listEntry.isCompletedList), title: normalizeBoardTableTitle(frontmatter.title), + start: String(frontmatter.start || '').trim(), due: String(frontmatter.due || '').trim(), labels: Array.isArray(frontmatter.labels) ? frontmatter.labels.map((labelId) => String(labelId)) : [], body, taskSummary: getTaskListSummary(body), + taskStartDates: typeof getTaskListStartDates === 'function' ? getTaskListStartDates(body) : [], + incompleteTaskStartDates: typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(body) : [], taskDueDates: getTaskListDueDates(body), incompleteTaskDueDates: getIncompleteTaskListDueDates(body), linkedObjectCount: typeof getFrontmatterLinkedObjectCount === 'function' @@ -439,6 +450,50 @@ function getBoardTableDisplayDueDates(entry) { }; } +function getBoardTableDisplayStartDates(entry) { + if (entry.start) { + return { + startDates: [entry.start], + prefix: '', + }; + } + + const taskStartDates = Array.isArray(entry.incompleteTaskStartDates) && entry.incompleteTaskStartDates.length > 0 + ? entry.incompleteTaskStartDates + : []; + + return { + startDates: getCardFilterDueDates('', taskStartDates), + prefix: 'Task: ', + }; +} + +async function createBoardTableStartCell(entry) { + const cell = document.createElement('td'); + cell.className = 'board-table-cell board-table-cell-start'; + + const displayStart = getBoardTableDisplayStartDates(entry); + const firstStartDate = displayStart.startDates[0] || ''; + if (!firstStartDate) { + const empty = document.createElement('span'); + empty.className = 'board-table-empty-value'; + empty.textContent = 'None'; + cell.appendChild(empty); + return cell; + } + + const startEl = document.createElement('span'); + startEl.className = 'board-table-start'; + const formattedStart = await window.board.formatDueDate(firstStartDate); + const extraCount = Math.max(0, displayStart.startDates.length - 1); + startEl.textContent = `${displayStart.prefix}${formattedStart}${extraCount > 0 ? ` +${extraCount}` : ''}`; + startEl.title = formatLongDueDateLabel(firstStartDate); + setDueDateVisualClass(startEl, firstStartDate); + cell.appendChild(startEl); + + return cell; +} + async function createBoardTableDueCell(entry) { const cell = document.createElement('td'); cell.className = 'board-table-cell board-table-cell-due'; @@ -555,6 +610,7 @@ async function createBoardTableRow(entry, listOptions) { toggleEditCardModal(entry.cardPath); }); + row.appendChild(await createBoardTableStartCell(entry)); row.appendChild(await createBoardTableDueCell(entry)); row.appendChild(createBoardTableTimestampCell(entry, 'updatedAt')); row.appendChild(createBoardTableTimestampCell(entry, 'createdAt')); diff --git a/app/cards/createCardElement.js b/app/cards/createCardElement.js index 0608870..98d1e5b 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -5,11 +5,14 @@ async function createCardElement(cardPath) { ? isBoardListCompletedByWorkflow(listDirectoryName) : false; const titleContent = card.frontmatter.title || ''; + let startDateValue = String(card.frontmatter.start || '').trim(); let dueDateValue = String(card.frontmatter.due || '').trim(); let selectedLabelIds = Array.isArray(card.frontmatter.labels) ? card.frontmatter.labels.map((labelId) => String(labelId)) : []; const taskSummary = getTaskListSummary(card.body); + const taskStartDates = typeof getTaskListStartDates === 'function' ? getTaskListStartDates(card.body) : []; + const incompleteTaskStartDates = typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(card.body) : taskStartDates; const taskDueDates = getTaskListDueDates(card.body); const incompleteTaskDueDates = getIncompleteTaskListDueDates(card.body); const linkedObjectCount = typeof getFrontmatterLinkedObjectCount === 'function' @@ -58,6 +61,19 @@ async function createCardElement(cardPath) { const metadata = document.createElement('div'); metadata.className = 'metadata'; + const startButton = document.createElement('button'); + startButton.type = 'button'; + startButton.className = 'metadata-action start-date-action'; + + const startIcon = document.createElement('i'); + startIcon.setAttribute('data-feather', 'play-circle'); + startButton.appendChild(startIcon); + + const formattedStart = document.createElement('span'); + formattedStart.className = 'formatted-date'; + startButton.appendChild(formattedStart); + metadata.appendChild(startButton); + const dueButton = document.createElement('button'); dueButton.type = 'button'; dueButton.className = 'metadata-action due-date-action'; @@ -99,6 +115,32 @@ async function createCardElement(cardPath) { cardLabelsWrap.className = 'card-labels'; metadata.appendChild(cardLabelsWrap); + function getAllCardFilterDates() { + return getCardFilterDueDates( + [startDateValue, dueDateValue], + [...taskStartDates, ...taskDueDates], + ); + } + + function getActiveCardFilterDates() { + return getActiveBoardFilterDueDates( + [startDateValue, dueDateValue], + [...taskStartDates, ...taskDueDates], + [...incompleteTaskStartDates, ...incompleteTaskDueDates], + ); + } + + async function renderStartDateDisplay() { + if (!startDateValue) { + formattedStart.textContent = ''; + setDueDateVisualClass(startButton, ''); + return; + } + + formattedStart.textContent = await window.board.formatDueDate(startDateValue); + setDueDateVisualClass(startButton, startDateValue); + } + async function renderDueDateDisplay() { if (!dueDateValue) { formattedDue.textContent = ''; @@ -111,16 +153,26 @@ async function createCardElement(cardPath) { } function setMetadataActionVisibility() { + const hasStartDate = startDateValue.length > 0; const hasDueDate = dueDateValue.length > 0; const hasLabels = selectedLabelIds.length > 0; const hasTasks = taskSummary.total > 0; const hasLinkedObjects = linkedObjectCount > 0; - const hasAnyMetadata = hasDueDate || hasLabels || hasTasks || hasLinkedObjects; + const hasAnyMetadata = hasStartDate || hasDueDate || hasLabels || hasTasks || hasLinkedObjects; metadata.classList.toggle('metadata-discovery', !hasAnyMetadata); + startButton.classList.toggle('metadata-action-empty', !hasStartDate); dueButton.classList.toggle('metadata-action-empty', !hasDueDate); labelButton.classList.toggle('metadata-action-empty', !hasLabels); + if (hasStartDate) { + startButton.title = 'Change start date'; + startButton.setAttribute('aria-label', 'Change start date'); + } else { + startButton.title = 'Set start date'; + startButton.setAttribute('aria-label', 'Set start date'); + } + if (hasDueDate) { dueButton.title = 'Change due date'; dueButton.setAttribute('aria-label', 'Change due date'); @@ -171,6 +223,22 @@ async function createCardElement(cardPath) { setMetadataActionVisibility(); } + async function updateCardStartDate(nextStartDateValue) { + startDateValue = String(nextStartDateValue || '').trim(); + const nextStartDate = startDateValue.length > 0 ? startDateValue : null; + + card.frontmatter.start = nextStartDate; + await window.board.updateFrontmatter(cardPath, { start: nextStartDate }); + await renderStartDateDisplay(); + setMetadataActionVisibility(); + + const cardDates = getAllCardFilterDates(); + const activeFilterDates = getActiveCardFilterDates(); + if (isBoardLabelFilterActive() && !cardMatchesBoardLabelFilter(selectedLabelIds, cardDates, activeFilterDates, { isCompletedList })) { + await renderBoard(); + } + } + async function updateCardDueDate(nextDueDateValue) { dueDateValue = String(nextDueDateValue || '').trim(); const nextDueDate = dueDateValue.length > 0 ? dueDateValue : null; @@ -180,9 +248,9 @@ async function createCardElement(cardPath) { await renderDueDateDisplay(); setMetadataActionVisibility(); - const cardDueDates = getCardFilterDueDates(dueDateValue, taskDueDates); - const activeFilterDueDates = getActiveBoardFilterDueDates(dueDateValue, taskDueDates, incompleteTaskDueDates); - if (isBoardLabelFilterActive() && !cardMatchesBoardLabelFilter(selectedLabelIds, cardDueDates, activeFilterDueDates, { isCompletedList })) { + const cardDates = getAllCardFilterDates(); + const activeFilterDates = getActiveCardFilterDates(); + if (isBoardLabelFilterActive() && !cardMatchesBoardLabelFilter(selectedLabelIds, cardDates, activeFilterDates, { isCompletedList })) { await renderBoard(); } } @@ -196,9 +264,9 @@ async function createCardElement(cardPath) { await window.board.updateFrontmatter(cardPath, { labels: selectedLabelIds }); renderCardLabels(); - const cardDueDates = getCardFilterDueDates(dueDateValue, taskDueDates); - const activeFilterDueDates = getActiveBoardFilterDueDates(dueDateValue, taskDueDates, incompleteTaskDueDates); - if (isBoardLabelFilterActive() && !cardMatchesBoardLabelFilter(selectedLabelIds, cardDueDates, activeFilterDueDates, { isCompletedList })) { + const cardDates = getAllCardFilterDates(); + const activeFilterDates = getActiveCardFilterDates(); + if (isBoardLabelFilterActive() && !cardMatchesBoardLabelFilter(selectedLabelIds, cardDates, activeFilterDates, { isCompletedList })) { await renderBoard(); } } @@ -214,9 +282,22 @@ async function createCardElement(cardPath) { ); } + await renderStartDateDisplay(); await renderDueDateDisplay(); renderCardLabels(); + startButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + openDueDatePickerAtTrigger({ + triggerElement: startButton, + dueDateValue: startDateValue, + onSelect: async (value) => { + await updateCardStartDate(value); + }, + }); + }); + dueButton.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); @@ -252,8 +333,8 @@ async function createCardElement(cardPath) { const matchesLabelFilter = cardMatchesBoardLabelFilter( selectedLabelIds, - getCardFilterDueDates(dueDateValue, taskDueDates), - getActiveBoardFilterDueDates(dueDateValue, taskDueDates, incompleteTaskDueDates), + getAllCardFilterDates(), + getActiveCardFilterDates(), { isCompletedList }, ); const matchesSearchFilter = cardMatchesBoardSearch(card.frontmatter.title, card.body); diff --git a/app/cards/processAddNewCard.js b/app/cards/processAddNewCard.js index 12be880..7a9a35a 100644 --- a/app/cards/processAddNewCard.js +++ b/app/cards/processAddNewCard.js @@ -19,7 +19,13 @@ async function processAddNewCard( cardName, listPath, options = {} ){ const cardPath = listPath + fileName; - await window.board.createCard( cardPath, cardName); + const initialFrontmatter = options && options.frontmatter && typeof options.frontmatter === 'object' + ? options.frontmatter + : {}; + + await window.board.createCard( cardPath, cardName, { + frontmatter: initialFrontmatter, + }); const shouldRerenderActiveBoard = !targetBoardRoot || targetBoardRoot === activeBoardRoot; await closeAllModals(createCloseAllModalsRequest(), { rerender: shouldRerenderActiveBoard }); diff --git a/app/init.js b/app/init.js index e770444..8e862fb 100644 --- a/app/init.js +++ b/app/init.js @@ -1025,9 +1025,17 @@ async function init() { if (!activeListPath) { return; } - await processAddNewCard(userInput.value, activeListPath.value, { openAfterCreate: true }); + await processAddNewCard(userInput.value, activeListPath.value, { + openAfterCreate: true, + frontmatter: typeof getCardCreationFrontmatter === 'function' + ? getCardCreationFrontmatter('add-card') + : {}, + }); userInput.value = ''; activeListPath.value = ''; + if (typeof resetCardCreationLabelSelection === 'function') { + resetCardCreationLabelSelection('add-card'); + } }); const userInputCardName = document.getElementById('userInputCardName'); userInputCardName.addEventListener('keydown', async (key) => { diff --git a/app/listeners/window.js b/app/listeners/window.js index d308942..e5cccc6 100644 --- a/app/listeners/window.js +++ b/app/listeners/window.js @@ -697,6 +697,36 @@ async function renderQuickAddListOptions(boardRoot, selectedListPath = '') { } } +function syncQuickAddLabelButtonForBoard(boardRoot) { + if (typeof initializeCardCreationLabelButton === 'function') { + initializeCardCreationLabelButton('quick-add'); + } + + const button = document.getElementById('quickAddCardLabelButton'); + if (!button) { + return; + } + + const normalizedTargetBoard = typeof normalizeBoardPath === 'function' + ? normalizeBoardPath(boardRoot) + : String(boardRoot || '').trim(); + const normalizedActiveBoard = typeof normalizeBoardPath === 'function' + ? normalizeBoardPath(window.boardRoot || '') + : String(window.boardRoot || '').trim(); + const canUseActiveBoardLabels = Boolean(normalizedTargetBoard && normalizedTargetBoard === normalizedActiveBoard); + + button.disabled = !canUseActiveBoardLabels; + if (!canUseActiveBoardLabels) { + if (typeof resetCardCreationLabelSelection === 'function') { + resetCardCreationLabelSelection('quick-add'); + } + button.title = 'Labels can be set when Quick Add targets the current board'; + button.setAttribute('aria-label', 'Labels unavailable for this board'); + } else if (typeof renderCardCreationLabelButton === 'function') { + renderCardCreationLabelButton('quick-add'); + } +} + async function submitQuickAddCardModal(options = {}) { const cardName = document.getElementById('userInputCardName'); const listPath = document.getElementById('userInputListPath'); @@ -715,9 +745,20 @@ async function submitQuickAddCardModal(options = {}) { const cardPath = await processAddNewCard(cardName.value, listPath.value, { boardRoot: boardPath ? boardPath.value : '', openAfterCreate: Boolean(options.openAfterCreate), + frontmatter: ( + boardPath && + typeof normalizeBoardPath === 'function' && + normalizeBoardPath(boardPath.value) === normalizeBoardPath(window.boardRoot || '') && + typeof getCardCreationFrontmatter === 'function' + ) + ? getCardCreationFrontmatter('quick-add') + : {}, }); cardName.value = ''; + if (typeof resetCardCreationLabelSelection === 'function') { + resetCardCreationLabelSelection('quick-add'); + } return cardPath; } finally { if (submitButton) { @@ -729,6 +770,10 @@ async function submitQuickAddCardModal(options = {}) { async function openAddCardFromShortcut(options = {}) { closePlannerBeforeBoardCreationShortcut(); + if (typeof resetCardCreationLabelSelection === 'function') { + resetCardCreationLabelSelection('quick-add'); + } + const userInputBoardPath = document.getElementById('userInputBoardPath'); const cardName = document.getElementById('userInputCardName'); const selectedBoardRoot = renderQuickAddBoardOptions(options.boardRoot || window.boardRoot); @@ -736,6 +781,7 @@ async function openAddCardFromShortcut(options = {}) { if (userInputBoardPath) { userInputBoardPath.onchange = async () => { const selectedBoardRoot = userInputBoardPath.value; + syncQuickAddLabelButtonForBoard(selectedBoardRoot); if (typeof waitForNativeMenuTrackingToSettle === 'function') { await waitForNativeMenuTrackingToSettle(); } @@ -748,6 +794,7 @@ async function openAddCardFromShortcut(options = {}) { } await renderQuickAddListOptions(selectedBoardRoot); + syncQuickAddLabelButtonForBoard(selectedBoardRoot); if (typeof setBoardInteractive === 'function') { setBoardInteractive(false); diff --git a/app/lists/listActionsPopover.js b/app/lists/listActionsPopover.js index 10b7c15..07459f5 100644 --- a/app/lists/listActionsPopover.js +++ b/app/lists/listActionsPopover.js @@ -53,6 +53,12 @@ function configureAddCardModal(listPath) { } hiddenListPath.value = normalizeListPathForCardCreation(listPath); + if (typeof resetCardCreationLabelSelection === 'function') { + resetCardCreationLabelSelection('add-card'); + } + if (typeof initializeCardCreationLabelButton === 'function') { + initializeCardCreationLabelButton('add-card'); + } btnAddCard.onclick = async (event) => { event.stopPropagation(); @@ -64,9 +70,15 @@ function configureAddCardModal(listPath) { await processAddNewCard(userInput.value, activeListPath.value, { openAfterCreate: Boolean(event && event.shiftKey), + frontmatter: typeof getCardCreationFrontmatter === 'function' + ? getCardCreationFrontmatter('add-card') + : {}, }); userInput.value = ''; activeListPath.value = ''; + if (typeof resetCardCreationLabelSelection === 'function') { + resetCardCreationLabelSelection('add-card'); + } }; } diff --git a/app/modals/toggleEditCardModal.js b/app/modals/toggleEditCardModal.js index c963d2c..d91a5c1 100644 --- a/app/modals/toggleEditCardModal.js +++ b/app/modals/toggleEditCardModal.js @@ -996,6 +996,8 @@ function renderCardEditorRelatedNotes(frontmatter = {}) { } async function renderActiveEditorMetadata(frontmatter = {}, timestamps) { + const cardEditorCardStartDateDisplay = document.getElementById('cardEditorCardStartDateDisplay'); + const cardEditorSetStartDateLink = document.getElementById('cardEditorSetStartDateLink'); const cardEditorCardDueDateDisplay = document.getElementById('cardEditorCardDueDateDisplay'); const cardEditorSetDueDateLink = document.getElementById('cardEditorSetDueDateLink'); @@ -1005,17 +1007,26 @@ async function renderActiveEditorMetadata(frontmatter = {}, timestamps) { renderCardEditorTimestamps(timestamps); } - if (!cardEditorCardDueDateDisplay) { - return; + if (cardEditorCardStartDateDisplay) { + const startValue = String(frontmatter.start || '').trim(); + if (startValue) { + cardEditorCardStartDateDisplay.textContent = await window.board.formatDueDate(startValue); + setDueDateVisualClass(cardEditorSetStartDateLink, startValue); + } else { + cardEditorCardStartDateDisplay.textContent = ''; + setDueDateVisualClass(cardEditorSetStartDateLink, ''); + } } - const dueValue = String(frontmatter.due || '').trim(); - if (dueValue) { - cardEditorCardDueDateDisplay.textContent = await window.board.formatDueDate(dueValue); - setDueDateVisualClass(cardEditorSetDueDateLink, dueValue); - } else { - cardEditorCardDueDateDisplay.textContent = ''; - setDueDateVisualClass(cardEditorSetDueDateLink, ''); + if (cardEditorCardDueDateDisplay) { + const dueValue = String(frontmatter.due || '').trim(); + if (dueValue) { + cardEditorCardDueDateDisplay.textContent = await window.board.formatDueDate(dueValue); + setDueDateVisualClass(cardEditorSetDueDateLink, dueValue); + } else { + cardEditorCardDueDateDisplay.textContent = ''; + setDueDateVisualClass(cardEditorSetDueDateLink, ''); + } } } @@ -1952,6 +1963,22 @@ function getTaskLineDueControlIconMarkup(hasDueDate) { return ''; } +function getTaskLineStartControlIconMarkup(hasStartDate) { + if ( + window.feather && + window.feather.icons && + typeof window.feather.icons['play-circle']?.toSvg === 'function' + ) { + return window.feather.icons['play-circle'].toSvg({ + width: 16, + height: 16, + stroke: 'currentColor', + }); + } + + return ''; +} + function getTaskLineCheckboxIconMarkup(isCompleted) { const iconName = isCompleted ? 'check-square' : 'square'; if ( @@ -2247,7 +2274,7 @@ function setupTaskLineDueDateControls(editor) { continue; } - const controlLeft = Math.max(1, Math.round(linePosition.left - textarea.scrollLeft - 38)); + const controlLeft = Math.max(1, Math.round(linePosition.left - textarea.scrollLeft - 58)); const checkbox = document.createElement('button'); checkbox.type = 'button'; @@ -2283,12 +2310,58 @@ function setupTaskLineDueDateControls(editor) { layer.appendChild(checkbox); + const startButton = document.createElement('button'); + startButton.type = 'button'; + startButton.className = 'task-line-start-control'; + startButton.dataset.lineIndex = String(taskItem.lineIndex); + startButton.style.top = `${buttonTop}px`; + startButton.style.left = `${Math.round(controlLeft + 20)}px`; + + if (taskItem.start) { + const startLabel = formatLongDueDateLabel(taskItem.start); + startButton.classList.add('has-start'); + startButton.title = `Starts ${startLabel}`; + startButton.setAttribute('aria-label', `Starts ${startLabel}. Change start date.`); + } else { + startButton.title = 'Set task start date'; + startButton.setAttribute('aria-label', 'Set task start date'); + } + + startButton.innerHTML = getTaskLineStartControlIconMarkup(Boolean(taskItem.start)); + startButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + + const targetLineIndex = Number(startButton.dataset.lineIndex); + const liveTaskItems = parseTaskListItems(textarea.value); + const liveTaskItem = liveTaskItems.find((item) => item.lineIndex === targetLineIndex); + if (!liveTaskItem) { + return; + } + + openDueDatePickerAtTrigger({ + triggerElement: startButton, + dueDateValue: liveTaskItem.start, + onSelect: async (value) => { + const nextValue = setTaskListItemStartDateByLineIndex(textarea.value, targetLineIndex, value); + if (nextValue === textarea.value) { + return; + } + + const caretPosition = getLineEndOffsetByLineIndex(nextValue, targetLineIndex); + applyEditorTextareaValuePreservingScroll(textarea, nextValue, caretPosition); + }, + }); + }); + + layer.appendChild(startButton); + const button = document.createElement('button'); button.type = 'button'; button.className = 'task-line-due-control'; button.dataset.lineIndex = String(taskItem.lineIndex); button.style.top = `${buttonTop}px`; - button.style.left = `${Math.round(controlLeft + 20)}px`; + button.style.left = `${Math.round(controlLeft + 40)}px`; if (taskItem.due) { const dueLabel = formatLongDueDateLabel(taskItem.due); @@ -2420,6 +2493,8 @@ async function toggleEditCardModal(cardPath, options = {}) { }; const cardEditorTitle = document.getElementById('cardEditorTitle'); + const cardEditorCardStartDateDisplay = document.getElementById('cardEditorCardStartDateDisplay'); + const cardEditorSetStartDateLink = document.getElementById('cardEditorSetStartDateLink'); const cardEditorCardDueDateDisplay = document.getElementById('cardEditorCardDueDateDisplay'); const cardEditorSetDueDateLink = document.getElementById('cardEditorSetDueDateLink'); const cardEditorSetLabelsLink = document.getElementById('cardEditorSetLabelsLink'); @@ -2428,12 +2503,19 @@ async function toggleEditCardModal(cardPath, options = {}) { setActiveEditorDiskState(cardPath, card); cardEditorCardPath.value = cardPath; cardEditorTitle.textContent = card.frontmatter.title || ''; + cardEditorCardStartDateDisplay.textContent = ''; + setDueDateVisualClass(cardEditorSetStartDateLink, ''); cardEditorCardDueDateDisplay.textContent = ''; setDueDateVisualClass(cardEditorSetDueDateLink, ''); setEditorLabelDisplay(card.frontmatter.labels); renderCardEditorRelatedNotes(card.frontmatter); renderCardEditorTimestamps(card.timestamps); + if (card.frontmatter.start) { + cardEditorCardStartDateDisplay.textContent = await window.board.formatDueDate(card.frontmatter.start); + setDueDateVisualClass(cardEditorSetStartDateLink, card.frontmatter.start); + } + if (card.frontmatter.due) { cardEditorCardDueDateDisplay.textContent = await window.board.formatDueDate(card.frontmatter.due); setDueDateVisualClass(cardEditorSetDueDateLink, card.frontmatter.due); @@ -2478,6 +2560,22 @@ async function toggleEditCardModal(cardPath, options = {}) { await handleNotesSave(cardEditorContents[0].value,false); }; + const openStartDatePickerControl = (e) => { + e.preventDefault(); + e.stopPropagation(); + const editorFrontmatter = getEditorFrontmatter(); + openDueDatePickerAtTrigger({ + triggerElement: cardEditorSetStartDateLink, + dueDateValue: editorFrontmatter.start, + onSelect: async (value) => { + await handleMetadataSave(value, 'start'); + }, + }); + }; + if (cardEditorSetStartDateLink) { + cardEditorSetStartDateLink.onclick = openStartDatePickerControl; + } + const openDueDatePickerControl = (e) => { e.preventDefault(); e.stopPropagation(); @@ -2639,20 +2737,21 @@ async function handleNotesSave(value,instance) { }; async function handleMetadataSave(value,metaName) { - if (metaName !== 'due') { + const normalizedMetaName = String(metaName || '').trim(); + if (normalizedMetaName !== 'start' && normalizedMetaName !== 'due') { return; } const cardEditorContents = document.getElementsByClassName('overtype-input'); const frontmatter = getEditorFrontmatter(); - const normalizedDueValue = value instanceof Date + const normalizedDateValue = value instanceof Date ? value.toISOString().slice(0, 10) : String(value || '').trim(); const normalizedFrontmatter = await window.board.normalizeFrontmatter({ ...frontmatter, - due: normalizedDueValue.length > 0 ? normalizedDueValue : null, + [normalizedMetaName]: normalizedDateValue.length > 0 ? normalizedDateValue : null, }); setEditorFrontmatter(normalizedFrontmatter); @@ -2661,16 +2760,7 @@ async function handleMetadataSave(value,metaName) { await flushEditorSaveIfNeeded(); await enqueueEditorSave(pendingEditorBody); - const cardEditorCardDueDateDisplay = document.getElementById('cardEditorCardDueDateDisplay'); - const cardEditorSetDueDateLink = document.getElementById('cardEditorSetDueDateLink'); - - if ( normalizedFrontmatter.due ) { - cardEditorCardDueDateDisplay.textContent = await window.board.formatDueDate(normalizedFrontmatter.due); - setDueDateVisualClass(cardEditorSetDueDateLink, normalizedFrontmatter.due); - } else { - cardEditorCardDueDateDisplay.textContent = ''; - setDueDateVisualClass(cardEditorSetDueDateLink, ''); - } + await renderActiveEditorMetadata(normalizedFrontmatter); return; }; diff --git a/app/utilities/taskList.js b/app/utilities/taskList.js index 73bd216..b35eb0f 100644 --- a/app/utilities/taskList.js +++ b/app/utilities/taskList.js @@ -1,8 +1,8 @@ const TASK_LIST_ITEM_PATTERN = /^(\s*[-*+]\s*\[([\sxXโœ“โœ”]*)\]\s*)(.*)$/; -const TASK_DUE_MARKER_PATTERN = /^\(due:\s*(\d{4}-\d{2}-\d{2})\)\s*/i; -const TASK_DUE_MARKER_LOOSE_PATTERN = /^\(due:\s*([^)]+)\)\s*/i; +const TASK_DATE_MARKER_PATTERN = /^\((due|start|scheduled):\s*(\d{4}-\d{2}-\d{2})\)\s*/i; +const TASK_DATE_MARKER_LOOSE_PATTERN = /^\((due|start|scheduled):\s*([^)]+)\)\s*/i; -function normalizeTaskDueDateValue(dateValue) { +function normalizeTaskDateValue(dateValue) { const normalized = String(dateValue || '').trim(); if (!normalized) { return ''; @@ -16,6 +16,48 @@ function normalizeTaskDueDateValue(dateValue) { return normalized; } +function normalizeTaskDueDateValue(dateValue) { + return normalizeTaskDateValue(dateValue); +} + +function normalizeTaskStartDateValue(dateValue) { + return normalizeTaskDateValue(dateValue); +} + +function parseTaskDateMarkers(contentValue) { + let remaining = String(contentValue || '').replace(/^\s+/, ''); + let due = ''; + let start = ''; + let consumedAnyMarker = false; + + while (remaining) { + const strictMatch = remaining.match(TASK_DATE_MARKER_PATTERN); + const looseMatch = remaining.match(TASK_DATE_MARKER_LOOSE_PATTERN); + const markerMatch = strictMatch || looseMatch; + if (!markerMatch) { + break; + } + + consumedAnyMarker = true; + const markerName = String(markerMatch[1] || '').trim().toLowerCase(); + const markerValue = strictMatch ? normalizeTaskDateValue(markerMatch[2]) : ''; + if (markerValue) { + if (markerName === 'due') { + due = markerValue; + } else { + start = markerValue; + } + } + remaining = remaining.slice(markerMatch[0].length); + } + + return { + due, + start, + contentWithoutDateMarkers: consumedAnyMarker ? remaining : String(contentValue || '').replace(/^\s+/, ''), + }; +} + function parseTaskListItemLine(lineValue) { const normalizedLine = String(lineValue || ''); const match = normalizedLine.match(TASK_LIST_ITEM_PATTERN); @@ -34,24 +76,16 @@ function parseTaskListItemLine(lineValue) { const content = String(match[3] || ''); const normalizedContent = content.replace(/^\s+/, ''); - let contentWithoutDue = normalizedContent; - const looseMarkerMatch = normalizedContent.match(TASK_DUE_MARKER_LOOSE_PATTERN); - if (looseMarkerMatch) { - contentWithoutDue = normalizedContent.slice(looseMarkerMatch[0].length); - } - - let due = ''; - const strictMarkerMatch = normalizedContent.match(TASK_DUE_MARKER_PATTERN); - if (strictMarkerMatch) { - due = normalizeTaskDueDateValue(strictMarkerMatch[1]); - } + const dateMarkers = parseTaskDateMarkers(normalizedContent); return { prefix, isCompleted, content, - contentWithoutDue, - due, + contentWithoutDue: dateMarkers.contentWithoutDateMarkers, + contentWithoutDateMarkers: dateMarkers.contentWithoutDateMarkers, + due: dateMarkers.due, + start: dateMarkers.start, }; } @@ -136,6 +170,32 @@ function getIncompleteTaskListDueDates(bodyValue) { return [...dueSet].sort(); } +function getTaskListStartDates(bodyValue) { + const items = parseTaskListItems(bodyValue); + const startSet = new Set(); + for (const item of items) { + if (item.start) { + startSet.add(item.start); + } + } + + return [...startSet].sort(); +} + +function getIncompleteTaskListStartDates(bodyValue) { + const items = parseTaskListItems(bodyValue); + const startSet = new Set(); + for (const item of items) { + if (item.isCompleted || !item.start) { + continue; + } + + startSet.add(item.start); + } + + return [...startSet].sort(); +} + function createTaskProgressBadge(taskSummary, className = '') { const summary = taskSummary && typeof taskSummary === 'object' ? taskSummary : {}; const total = Number(summary.total) || 0; @@ -168,7 +228,7 @@ function createTaskProgressBadge(taskSummary, className = '') { return badge; } -function setTaskListItemDueDateByLineIndex(bodyValue, lineIndex, dueDateValue) { +function setTaskListItemDateByLineIndex(bodyValue, lineIndex, dateKind, dateValue) { const body = String(bodyValue || ''); const requestedLineIndex = Number(lineIndex); if (!Number.isInteger(requestedLineIndex) || requestedLineIndex < 0) { @@ -185,15 +245,27 @@ function setTaskListItemDueDateByLineIndex(bodyValue, lineIndex, dueDateValue) { return body; } - const normalizedDueDate = normalizeTaskDueDateValue(dueDateValue); + const normalizedKind = String(dateKind || '').trim().toLowerCase() === 'start' ? 'start' : 'due'; + const normalizedDate = normalizeTaskDateValue(dateValue); const trimmedContent = String(parsedLine.contentWithoutDue || '').trimStart(); - const duePrefix = normalizedDueDate ? `(due: ${normalizedDueDate}) ` : ''; - lines[requestedLineIndex] = `${parsedLine.prefix}${duePrefix}${trimmedContent}`; + const startValue = normalizedKind === 'start' ? normalizedDate : parsedLine.start; + const dueValue = normalizedKind === 'due' ? normalizedDate : parsedLine.due; + const startPrefix = startValue ? `(start: ${startValue}) ` : ''; + const duePrefix = dueValue ? `(due: ${dueValue}) ` : ''; + lines[requestedLineIndex] = `${parsedLine.prefix}${startPrefix}${duePrefix}${trimmedContent}`; const newline = body.includes('\r\n') ? '\r\n' : '\n'; return lines.join(newline); } +function setTaskListItemDueDateByLineIndex(bodyValue, lineIndex, dueDateValue) { + return setTaskListItemDateByLineIndex(bodyValue, lineIndex, 'due', dueDateValue); +} + +function setTaskListItemStartDateByLineIndex(bodyValue, lineIndex, startDateValue) { + return setTaskListItemDateByLineIndex(bodyValue, lineIndex, 'start', startDateValue); +} + function setTaskListItemCompletionByLineIndex(bodyValue, lineIndex, isCompleted) { const body = String(bodyValue || ''); const requestedLineIndex = Number(lineIndex); diff --git a/docs/README.md b/docs/README.md index 1fc3abe..129bd6e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ If you want structured tool access from an agent, see [MCP Server](../MCP_README - Creating and organizing boards, lists, and cards - Drag-and-drop movement, including the empty insertion slot shown while dragging cards -- Due dates, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting in Table, Planner date views, and External Published Calendar +- Start dates, due dates, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting in Table, Planner date views, and External Published Calendar - Archiving and restoring cards and lists - Settings, including app-wide tooltips/notifications/Quick Add global shortcut/External Published Calendar and board-specific workflow, colors, labels, and imports - Obsidian integration, including boards stored inside vaults, Open With actions, generated Bases files, linked notes, missing-note handling, linked objects, dropped local-file linking, URL favicons, `signboard://` card/board links, and the optional Obsidian companion plugin diff --git a/docs/codex/FILE_STRUCTURE.md b/docs/codex/FILE_STRUCTURE.md index cf8efcd..a6d031c 100644 --- a/docs/codex/FILE_STRUCTURE.md +++ b/docs/codex/FILE_STRUCTURE.md @@ -27,17 +27,17 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/signboard.js` - Generated concatenated renderer file loaded by `index.html`. - `app/utilities/santizeFileName.js` - Filename sanitization + random suffix helper. -- `app/utilities/taskList.js` - Task checklist parser, due-marker helpers, all/open task due-date sets, task-summary counters, and task progress badge creation. +- `app/utilities/taskList.js` - Task checklist parser, start/due marker helpers, all/open task date sets, task-summary counters, and task progress badge creation. - `app/utilities/dueNotifications.js` - Due-notification collection + message formatting for card due dates and incomplete task due markers, skipping completed workflow lists. - `app/utilities/accessibility.js` - Shared renderer accessibility helpers for modal focus restoration/trapping, background inert state, live status announcements, stable DOM IDs, reduced-motion checks, and deferring DOM mutations until native menu/select popup tracking settles on macOS. - `app/utilities/cardDragTilt.js` - Shared card Sortable fallback options, drag tilt, reduced-motion handling, and drag text-selection lock used by Kanban and temporal card drag/drop. - `app/utilities/cardTimestamps.js` - Renderer card timestamp formatting helpers for editor metadata and Table age columns. - `app/utilities/linkedObjects.js` - Shared renderer helpers for counting structured `linked_objects` and legacy `related` links, plus paperclip count badge creation for Kanban/Table. - `app/appSettings.js` - Renderer app-settings state, app-wide tooltip/notification/Quick Add global shortcut/External Published Calendar controls, persistence scheduling, and one-time migration from legacy board settings. -- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` + label filters, with date filters ignoring completed task due markers and completed workflow lists), keyboard-operable card label popovers, Settings modal board panels/nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. +- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation, new-card label selection helpers, Settings modal board panels/nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. - `app/board/boardSearch.js` - Board search state, input handling for title/body filtering, and keyboard navigation from the search field through visible card results. -- `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card due/open task due markers, and source-list/source-board pills on temporal cards. -- `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Created/Updated age columns and linked-object counts, board filter/search reuse, Table sort controls, and list-column card moves through the top-of-list move IPC path. +- `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card start/due and open task start/due markers, and source-list/source-board pills on temporal cards. +- `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table sort controls, and list-column card moves through the top-of-list move IPC path. - `app/board/plannerView.js` - Workspace-level Planner overlay with Calendar, This Week, Day, and Agenda views across currently open boards, all/current/custom board scope controls, Planner-local search/date/completed-card/board/active-board-label filters, keyboard navigation for Planner search/filter controls, left-rail open/close behavior, and Planner card opening that switches the active board when needed. - `app/board/archiveBrowser.js` - Dedicated Archive modal UI, search-first archived card/list browsing with keyboard result navigation, detail-pane rendering, incremental result loading, and restore flows. - `app/board/boardTabs.js` - Open-board tab session state (restore/add/close/reorder), keyboard navigation/close behavior for visible tabs, responsive `N more` overflow for unbounded open boards, plus the shared safe board-switch helper used by tab clicks and the switcher. @@ -53,7 +53,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/modals/toggleAddCardModal.js` - Add-card modal position/toggle. - `app/modals/toggleAddListModal.js` - Add-list modal position/toggle. - `app/modals/toggleAddCardToListModal.js` - Cross-list add-card modal toggle. -- `app/modals/toggleEditCardModal.js` - Card editor open/save/archive/duplicate logic, Created/Updated timestamp display, active-card top-of-list moves from the dropdown/directional controls, debounced + serialized saves, clean-editor reloads after external/MCP card edits, fresh duplicate lifecycle metadata, raw body URL detection/open controls, linked-object paperclip controls including inline URL/app-link entry, Obsidian rename reconciliation between `linked_objects` and `related` wikilinks, missing-note status rendering with recreate/relink/remove actions, drag/drop local-file linking, Open With/Obsidian actions, and task-line due-date controls aligned from measured line coordinates. +- `app/modals/toggleEditCardModal.js` - Card editor open/save/archive/duplicate logic, card start/due metadata controls, Created/Updated timestamp display, active-card top-of-list moves from the dropdown/directional controls, debounced + serialized saves, clean-editor reloads after external/MCP card edits, fresh duplicate lifecycle metadata, raw body URL detection/open controls, linked-object paperclip controls including inline URL/app-link entry, Obsidian rename reconciliation between `linked_objects` and `related` wikilinks, missing-note status rendering with recreate/relink/remove actions, drag/drop local-file linking, Open With/Obsidian actions, and task-line start/due date controls aligned from measured line coordinates. - `app/listeners/window.js` - Keyboard shortcuts, menu/global-command listeners, Quick Add card modal wiring with board/list selection across open boards, board view switching, Planner toggle/view shortcut handling including all-open-board and current-board date-view scopes, Settings fallback handling, quick board switcher shortcut handling, color cycling, active-card move/archive shortcuts, active-editor closing for workspace-level shortcuts, and the `Cmd/Ctrl + /` helper modal behavior; keep `#modalKeyboardShortcuts` list in sync when adding/changing shortcuts. - `app/init.js` - App bootstrap, folder picker handling, top-level event wiring, Obsidian-vault-required info modal controls, sponsorship modal triggers, and external board-change auto-refresh sync loop, including clean open-editor refreshes. - `app/ui/theme.js` - Theme toggle + OverType theme integration, including the theme shortcut hint/state in the board menu. @@ -61,7 +61,7 @@ This map focuses on source and operational files. Large generated/vendor folders ## Shared/library code -- `lib/cardFrontmatter.js` - Card parse/normalize/read/write/update with legacy support. +- `lib/cardFrontmatter.js` - Card parse/normalize/read/write/update with legacy support, including `start` and `due` date normalization. - `lib/cardLifecycle.js` - Shared card lifecycle metadata helper for `createdAt`, compact `activity` trails, archive frontmatter state, and moved/restored transitions. - `lib/cardTimestamps.js` - Shared timestamp resolver for desktop reads, CLI card records/JSON output, and MCP card responses, preferring frontmatter/activity creation data and filesystem modification data. - `lib/cardOrdering.js` - Shared list-card ordering helper used by main-process/MCP restore and move flows to insert a card at the top while renumbering existing files. @@ -77,8 +77,8 @@ This map focuses on source and operational files. Large generated/vendor folders - `lib/cardBodyEdits.js` - Shared Markdown body-edit helpers for replacing heading sections, inserting text below headings, and appending timestamped note list items. - `lib/boardCreation.js` - Shared default board scaffolding for MCP and CLI-created boards, including default list folders and the starter card body/frontmatter. - `lib/mcpServer.js` - Headless MCP stdio server for agent access to board/list/card/settings/archive operations inside configured or desktop-trusted roots, safe board creation, archive browse/read/restore tools, Trello/Obsidian/Tasks.md imports, dry-run card writes, and timestamp/task metadata on card tools. -- `lib/cliApp.js` - CLI command parsing/output for `use`, `boards`, `lists`, `cards`, `archive`, `settings`, and path-based `import` commands, including board creation, card duplicate/template commands, section/note card edits, dry-run previews, JSON timestamp output, and `--task-status open|any` for card due filtering. -- `lib/cliBoard.js` - CLI list/card filesystem operations, record loading, card duplication/template creation, section/note body edits, explicit label clearing, due/search/label filtering, and created/updated age sorting; overdue task filtering defaults to incomplete/open task markers unless callers pass `--task-status any`. +- `lib/cliApp.js` - CLI command parsing/output for `use`, `boards`, `lists`, `cards`, `archive`, `settings`, and path-based `import` commands, including board creation, card duplicate/template commands, `--start` writes, section/note card edits, dry-run previews, JSON timestamp/date output, and `--task-status open|any` for card due filtering. +- `lib/cliBoard.js` - CLI list/card filesystem operations, record loading, card duplication/template creation, section/note body edits, explicit label clearing, due/search/label filtering, start/task-date metadata output, and created/updated age sorting; overdue task filtering defaults to incomplete/open task markers unless callers pass `--task-status any`. - `lib/cliInstall.js` - User-level CLI shim + shell profile installation; packaged shims run `app.asar/bin/signboard.js` under `ELECTRON_RUN_AS_NODE` instead of launching the desktop lifecycle. ## Scripts (`scripts/`) @@ -96,7 +96,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `scripts/test-import-obsidian.js` - Obsidian importer assertions for kanban/task/CardBoard cases, due conversion, and source-prefix naming. - `scripts/test-obsidian-integration.js` - Obsidian outbound integration assertions for URI/deep-link helpers, flat card properties, managed generated Bases YAML, linked notes, and linked-note resolution. - `scripts/test-obsidian-plugin.js` - Pure helper assertions for the optional Obsidian companion plugin. -- `scripts/test-task-list-parser.js` - Task checklist parser assertions (`completed/total` and task due-date extraction). +- `scripts/test-task-list-parser.js` - Task checklist parser assertions (`completed/total` and task start/due date extraction). - `scripts/migrate-legacy-cards.js` - Bulk migration to YAML frontmatter format. - `scripts/notarize.js` - electron-builder `afterSign` notarization hook. - `scripts/verify-release-assets.js` - Release checklist validator for updater metadata/assets across macOS/Windows/Linux plus curated public-download guidance. diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index cb05881..649c06c 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -164,8 +164,8 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Loads board label definitions and temporary filter state before rendering cards. - `app/board/tableView.js`: - Renders active-board cards in board/list order as a dense table. - - Shows `Updated`, `Created`, and linked-object count columns plus a compact sort control for board order, oldest/newest updated, oldest/newest created, due date, and title. - - Reuses board search, label filters, Today/Overdue date filters, task progress badges, linked-object counts, and completed-list workflow handling. + - Shows `Start`, `Due`, `Updated`, `Created`, and linked-object count columns plus a compact sort control for board order, oldest/newest updated, oldest/newest created, due date, and title. + - Reuses board search, label filters, Today/Overdue/next-range date filters, task progress badges, linked-object counts, and completed-list workflow handling. - Moves a card to another list through the row list dropdown by calling the same top-of-list move IPC path as the card editor. - Defers row list dropdown DOM updates until macOS native menu tracking has settled. - `app/board/archiveBrowser.js`: @@ -178,15 +178,15 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Opens the `Cmd/Ctrl + K` board switcher overlay. - Filters currently open boards by visible board name, highlights autocomplete results, closes open boards from result rows, and delegates switching to the shared board switch helper. - `app/board/boardViews.js`: - - Owns shared Kanban/Planner temporal helpers such as calendar math, week math, card collection, open task due-date placement, and temporal card rendering. + - Owns shared Kanban/Planner temporal helpers such as calendar math, week math, card collection, open task start/due date placement, and temporal card rendering. - Owns board-facing Kanban/Table view state and the Board menu view popover. - Shows task progress badges and source-list/source-board pills on temporal cards, tinting source-board pills from each board's color scheme when available. - `app/board/plannerView.js`: - Owns the workspace Planner overlay opened from the left rail or `Cmd/Ctrl + Shift + P`. - Scopes Planner data to currently open board tabs only and defaults to all open boards. - Offers quick `All Boards` and `Current Board` scope controls plus custom board selection in the filter menu. - - Renders Planner Calendar, This Week, Day, and Agenda views from card due dates and incomplete task-level due markers. - - Uses Planner-local search plus `Today` / `Overdue`, completed-card visibility, and open-board filters; label filters appear only when scoped to the active board. + - Renders Planner Calendar, This Week, Day, and Agenda views from card start/due dates and incomplete task-level start/due markers. + - Uses Planner-local search plus `Today` / `Overdue` / next-range date filters, completed-card visibility, and open-board filters; label filters appear only when scoped to the active board. - Lets keyboard users move from Planner search into visible Planner cards, move between cards with arrows, return to search with `Esc`, and traverse the Planner filter popover with arrows. - Hides cards from completed workflow lists by default while preserving their due-date metadata; the Planner filter menu can show completed dated cards when needed. - Opens Planner cards through the normal editor, switching the active board behind the overlay first when the card belongs to a different board. @@ -203,7 +203,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Keeps the popover labelled, focuses the first enabled action on open, supports arrow-key / `Home` / `End` / `Esc` option navigation, and announces completed list actions through the shared live status helper. - `app/cards/createCardElement.js`: - Reads card frontmatter/body preview. - - Computes task summary + task due dates from card body checklist lines. + - Computes task summary + task start/due dates from card body checklist lines. - Shows task progress badge on board cards. - Shows linked-object count badges on board cards. - Shows label chips and a tag-icon picker on each card. @@ -211,14 +211,14 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Renders each card as a list item with a native button title so cards can be opened by keyboard and announced with stable labels. - `app/board/boardLabels.js`: - Owns board label state in the renderer. - - Renders the header filter dropdown with mutually exclusive `Today` / `Overdue` date filters plus multi-select OR label filters. + - Renders the header filter dropdown with mutually exclusive `Today` / `Overdue` / next-range date filters plus multi-select OR label filters. - Keeps the header filter popover, card label popover, and Settings section nav keyboard-operable with arrow keys, `Home`, `End`, and opener focus restoration on popover `Esc`. - - Evaluates date filters from card due dates and incomplete task due markers, ignoring completed task due markers. + - Evaluates date filters from card start/due dates and incomplete task start/due markers, ignoring completed task date markers. - Combines date filters, label filters, and board search with AND logic when determining visibility. - Owns board workflow settings for completed-list auto-detection, ignored auto-detected lists, and manual completed-list selection. - Keeps filter state temporary only; opening or switching boards resets the active date + label filters. - Keeps the filter toolbar button icon-only and applies an accent-tinted active state when any filter is set; active summary text lives in tooltip/ARIA copy. - - Handles card label popovers, Settings modal board panels, and the board import UI/actions. + - Handles card label popovers with inline label creation, new-card label selection, Settings modal board panels, and the board import UI/actions. - Persists board labels through preload APIs. - `app/board/boardSearch.js`: - Stores the current search query/tokens. @@ -231,6 +231,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Quick Add card submissions can target any currently open/trusted board, request opening the created card immediately with the notes field focused, and switch to the target board first when `Shift + Enter` creates a card outside the active board. - `app/modals/toggleEditCardModal.js`: - Loads card into OverType editor. + - Displays and edits card-level start/due dates from frontmatter. - Displays quiet `Created` and `Updated` card timestamps from the normalized desktop read metadata. - Saves title/body/frontmatter through `window.board.writeCard`. - Debounces editor body writes and serializes save order to prevent stale overwrite races. @@ -239,15 +240,15 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Detects raw `http(s)`/`www` URLs in the body without rewriting Markdown, visually marks them in the OverType preview, and opens them through `window.electronAPI.openExternal` from the inline open button or Cmd/Ctrl-click. - Moves active cards to selected/adjacent lists from the list dropdown, arrow action, and keyboard shortcuts by calling the main-process `moveCardToTop` IPC path, which inserts at the top of the destination list. - Defers list-dropdown moves until macOS native menu tracking has settled before disabling controls or refreshing editor state. - - Renders task-line due-date controls at the start of each parsed checklist line in the editor. + - Renders task-line start/due date controls at the start of each parsed checklist line in the editor. - Uses measured textarea line-start coordinates for control placement so wrapped lines do not drift button positions. - - Handles due date picker, labels picker, linked-object paperclip menu, local-file drag/drop linking, duplicate, archive, and Open With actions. + - Handles date pickers, labels picker, linked-object paperclip menu, local-file drag/drop linking, duplicate, archive, and Open With actions. - The Open With menu covers default-app file actions and copied Signboard links for every board; Obsidian open/URI actions are shown only when the card is inside a detected vault. - Card duplication now resets archive/lifecycle fields and seeds a fresh `created` event. - `app/utilities/taskList.js`: - Parses checklist items from card markdown body. - - Computes task summary (`total`, `completed`, `remaining`) and task due-date sets. - - Creates task progress badge elements and updates task-line due markers by line index. + - Computes task summary (`total`, `completed`, `remaining`) and task start/due date sets. + - Creates task progress badge elements and updates task-line start/due markers by line index. - `app/utilities/dueNotifications.js`: - Collects due items from both card-level due dates and incomplete task-level due markers, skipping cards in completed workflow lists. - Builds notification body text that includes board/card title and task summary text for task due items. @@ -314,14 +315,16 @@ File: `lib/cardFrontmatter.js` - Legacy heading-only format (`# Title` first line) - Normalizes metadata: - `Title` -> `title` + - `scheduled` / `startDate` / `scheduledDate` / `Start-date` / `Start` / `Scheduled` -> `start` - `Due-date` -> `due` - `Labels` -> `labels` -- Standardizes due date to `YYYY-MM-DD` when possible. +- Standardizes start and due dates to `YYYY-MM-DD` when possible. - Ensures deterministic write order: 1) `title` - 2) optional `due` - 3) optional `labels` (non-empty only) - 4) other keys sorted alphabetically + 2) optional `start` + 3) optional `due` + 4) optional `labels` (non-empty only) + 5) other keys sorted alphabetically File: `lib/cardLifecycle.js` @@ -375,7 +378,7 @@ File: `lib/obsidianIntegration.js` ## Importers Files: `lib/importers/*` -- `lib/importers/trello.js` imports Trello board JSON into Signboard lists/cards, preserving labels, checklists, comments, attachments, due dates, and archive routing for closed Trello content. +- `lib/importers/trello.js` imports Trello board JSON into Signboard lists/cards, preserving labels, checklists, comments, attachments, start/due dates, and archive routing for closed Trello content. - `lib/importers/obsidian.js` imports: - markdown-backed `obsidian-kanban` boards - generic task-based Obsidian markdown scopes @@ -423,8 +426,8 @@ Files: `lib/importers/*` ### CLI internals - `lib/boardCreation.js` owns shared default board scaffolding for MCP and CLI-created boards. -- `lib/cliBoard.js` owns CLI list/card filesystem operations, including due filtering with `--due-source any|card|task`, `--task-status open|any`, card duplication/template creation, targeted Markdown section edits, timestamped notes, explicit label clearing, and card write dry-run payloads. -- `lib/taskList.js` exposes shared task parsing and due-date helpers for CLI filtering. +- `lib/cliBoard.js` owns CLI list/card filesystem operations, including due filtering with `--due-source any|card|task`, `--task-status open|any`, `--start` card writes, card duplication/template creation, targeted Markdown section edits, timestamped notes, explicit label clearing, and card write dry-run payloads. +- `lib/taskList.js` exposes shared task parsing and start/due date helpers for CLI filtering and JSON metadata. - `lib/cliApp.js` owns shared command parsing/output used by both the Node shim and Electron executable, including board creation, archive listing/read/restore flows, card write previews, and path-based Trello/Obsidian/Tasks.md imports. - `lib/cliInstall.js` owns user-level CLI shim + shell profile installation, including the packaged-app Node-mode wrapper. - `lib/cliState.js` persists the currently selected board for subsequent CLI commands. @@ -454,7 +457,7 @@ CLI overdue behavior: ### MCP smoke test - `npm run test:mcp` - Script: `scripts/test-mcp-server.js` -- Asserts card tool outputs include `taskSummary` + `taskDueDates`, verifies trusted-root config and board-name resolution, verifies archive browse/read/restore tools, and covers Trello/Obsidian/Tasks.md import tools. +- Asserts card tool outputs include `taskSummary` + task date metadata, verifies trusted-root config and board-name resolution, verifies archive browse/read/restore tools, and covers Trello/Obsidian/Tasks.md import tools. ### CLI smoke test - `npm run test:cli` @@ -478,7 +481,7 @@ CLI overdue behavior: ### Task list parser tests - `npm run test:task-list` - Script: `scripts/test-task-list-parser.js` -- Covers checklist completion variants and task due-date extraction. +- Covers checklist completion variants and task start/due date extraction. ### Due notification tests - `npm run test:due-notifications` diff --git a/docs/signboard-cli.md b/docs/signboard-cli.md index f3cc75d..054673e 100644 --- a/docs/signboard-cli.md +++ b/docs/signboard-cli.md @@ -12,7 +12,7 @@ This guide covers the Signboard command-line interface. - [Common Workflows](#common-workflows) - [Archive Workflows](#archive-workflows) - [Settings and Imports](#settings-and-imports) -- [Markdown and Due-Date Conventions](#markdown-and-due-date-conventions) +- [Markdown and Date Conventions](#markdown-and-date-conventions) - [Troubleshooting](#troubleshooting) ## How to Run It @@ -160,7 +160,7 @@ signboard cards read --card 003-ship-release-notes-ab123.md ```bash signboard cards create --list "To do" --title "Ship release notes" -signboard cards create --list "To do" --title "Write announcement" --due 2026-04-10 +signboard cards create --list "To do" --title "Write announcement" --start 2026-04-08 --due 2026-04-10 signboard cards create --list "To do" --title "Draft copy" --label Writing --label Marketing signboard cards create --from-card ab123 --list "Leads" --title "New lead" --remove-label Template ``` @@ -173,6 +173,7 @@ Create options: - `--body-file ` - `--from-card ` optional source card/template to copy - `--from-list ` optional source list disambiguation for `--from-card` +- `--start ` - `--due ` - `--label ` repeatable - `--remove-label ` repeatable with `--from-card` @@ -194,6 +195,7 @@ signboard cards create \ ```bash signboard cards edit --card ab123 --title "Ship v1.2.0" +signboard cards edit --card ab123 --start 2026-04-10 signboard cards edit --card ab123 --due 2026-04-12 signboard cards edit --card ab123 --due none signboard cards edit --card ab123 --move-to Doing @@ -215,6 +217,7 @@ Edit options: - `--append-body ` - `--replace-section ` with `--body` or `--body-file` - `--insert-after-heading ` with `--text` or `--text-file` +- `--start ` - `--due ` - `--set-label ` repeatable, replaces all labels - `--add-label ` repeatable @@ -240,6 +243,8 @@ Duplicate options: - `--list ` optional target list; defaults to the source list - `--title ` optional exact title; defaults to `Copy of <source title>` - `--body <text>` or `--body-file <path>` optional replacement body +- `--start <YYYY-MM-DD|none>` +- `--due <YYYY-MM-DD|none>` - `--label <ref>` repeatable, adds labels to the duplicate - `--remove-label <ref>` repeatable - `--clear-labels` @@ -354,7 +359,7 @@ signboard cards --due overdue --due-source task --task-status open --json ### Create a card and move it later ```bash -signboard cards create --list Backlog --title "Write release post" --label Docs --due 2026-04-08 +signboard cards create --list Backlog --title "Write release post" --label Docs --start 2026-04-06 --due 2026-04-08 signboard cards edit --card "Write release post" --move-to Doing ``` @@ -467,11 +472,11 @@ signboard import tasksmd --source ~/Projects/MyTasksBoard All import commands support `--json`. -## Markdown and Due-Date Conventions +## Markdown and Date Conventions Cards are Markdown files with frontmatter. The CLI reads and writes that structure for you. So you don't need to worry about that unless you are supplying your own files. -### Card due dates +### Card start and due dates Use ISO local dates: @@ -483,15 +488,19 @@ Examples: - `2026-04-02` - `2026-04-14` -- `none` to clear the card due date +- `none` to clear the card start or due date + +`start` is for the day work starts or becomes scheduled. `due` is the deadline. -### Checklist due dates +### Checklist start and due dates -Checklist items may also contain due dates in the body: +Checklist items may also contain start and due dates in the body: ```md -- [ ] (due: 2026-04-05) Review screenshots +- [ ] (start: 2026-04-02) Review screenshots +- [ ] (start: 2026-04-03) (due: 2026-04-05) Publish announcement +- [ ] (scheduled: 2026-04-06) Follow up with testers - [x] (due: 2026-04-01) Draft release notes ``` -Task due dates participate in CLI due-date filtering. Use `--task-status open` to limit task matches to unchecked items, or `--task-status any` to include checked task due markers when you want historical matches. +Task due dates participate in CLI due-date filtering. Start/scheduled task dates are returned in JSON metadata for agents and used by the desktop Planner/date-filter views. Use `--task-status open` to limit task due matches to unchecked items, or `--task-status any` to include checked task due markers when you want historical matches. diff --git a/docs/using-signboard.md b/docs/using-signboard.md index 00dff34..96f19b2 100644 --- a/docs/using-signboard.md +++ b/docs/using-signboard.md @@ -8,7 +8,7 @@ This guide covers the desktop app and the core workflow for managing a project i - [Create or Open a Board](#create-or-open-a-board) - [Work with Lists](#work-with-lists) - [Work with Cards](#work-with-cards) -- [Due Dates, Labels, and Checklists](#due-dates-labels-and-checklists) +- [Start Dates, Due Dates, Labels, and Checklists](#start-dates-due-dates-labels-and-checklists) - [Search, Filters, and Open Boards](#search-filters-and-open-boards) - [Board and Planner Views](#board-and-planner-views) - [Planner](#planner) @@ -152,10 +152,16 @@ New or edited cards include flat Obsidian-friendly properties such as `title`, ` Signboard also includes an optional desktop-only Obsidian companion plugin in `obsidian-plugin/`. Copy or symlink that folder into your vault as `.obsidian/plugins/signboard-companion` and enable it from Obsidian's Community plugins settings. The plugin can open and copy Signboard links, attach the active Obsidian note to a Signboard card, open cards by `obsidian://signboard?cardId=...`, and add a folder context-menu action named `Create Signboard`. That action asks first, then adds board metadata/list folders, treats existing child folders as lists, moves top-level Markdown notes into a To-do list, and opens the board in Signboard. When you delete an Obsidian note that is linked from Signboard cards, the plugin asks before removing those linked objects from the cards. -## Due Dates, Labels, and Checklists +## Start Dates, Due Dates, Labels, and Checklists These features are what make cards show up in Planner and filters. +### Card start dates + +Every card can have a start date in addition to a due date. Use a start date when work is scheduled to begin or become actionable before it is due. + +Start dates appear on cards, in Table's `Start` column, in Planner date views, and in date-aware filters. + ### Card due dates Every card can have a due date. @@ -167,23 +173,25 @@ Once a card has a due date, it becomes visible in: - Planner - daily due notifications if enabled in app settings -### Task list items with due dates +### Task list items with start and due dates -Signboard also understands due dates inside Markdown task lists. +Signboard also understands start and due dates inside Markdown task lists. Example: ```md - [ ] Draft release notes -- [ ] (due: 2026-04-05) Send beta build +- [ ] (start: 2026-04-02) Draft beta announcement +- [ ] (start: 2026-04-03) (due: 2026-04-05) Send beta build +- [ ] (scheduled: 2026-04-06) Follow up with testers - [x] Review copy ``` -Task due dates are separate from the cardโ€™s main due date. Open checklist item due dates are included in Planner and board date filters, so a card can surface because one of its unchecked checklist items is due even if the card itself has no top-level due date. Once that checklist item is checked off, its due date stays in the Markdown but no longer keeps the card in date-based views. CLI due filters expose `--task-status open|any` when you need to choose whether checked task due markers count. +Task dates are separate from the cardโ€™s main start and due dates. Open checklist item start/due dates are included in Planner and board date filters, so a card can surface because one of its unchecked checklist items is dated even if the card itself has no top-level date. Once that checklist item is checked off, its date stays in the Markdown but no longer keeps the card in date-based views. CLI due filters expose `--task-status open|any` when you need to choose whether checked task due markers count. ### Labels -Labels are defined per board. Add them in `Settings > Labels`, then assign them to cards from the card editor. +Labels are defined per board. Add them in `Settings > Labels`, or create a new label directly from the label picker while editing or creating a card. Labels are useful for: @@ -209,7 +217,7 @@ From the search field, press `Enter` or `Arrow Down` to focus the first visible ### Date and Label filters -Use the filter button in the header to narrow the visible cards by due today, overdue, and your board labels. +Use the filter button in the header to narrow the visible cards by `Today`, `Overdue`, `Next 7 days`, `Next 14 days`, `Next 30 days`, and your board labels. When a filter popover is open, use arrow keys, `Home`, and `End` to move through its controls. Press `Esc` to close the popover and return focus to the button that opened it. @@ -219,19 +227,19 @@ Board context can be Kanban or Table. Dated planning happens in Planner. ### Kanban -Kanban is the board view. Use it for day-to-day drag-and-drop organization. Cards show compact metadata for due dates, checklist progress, labels, and linked-object counts. +Kanban is the board view. Use it for day-to-day drag-and-drop organization. Cards show compact metadata for start dates, due dates, checklist progress, labels, and linked-object counts. ### Table -Table is an active-board view for scanning cards in board/list order. It uses the same board search, label filters, Today/Overdue date filters, task progress badges, linked-object counts, and completed-list workflow rules as Kanban. +Table is an active-board view for scanning cards in board/list order. It uses the same board search, label filters, date filters, task progress badges, linked-object counts, and completed-list workflow rules as Kanban. Open `Board menu > View > Table`. Click a card title or row to open the normal card editor. Use the row's list dropdown to move a card to another list; moved cards land at the top of the destination list. -Table includes `Updated` and `Created` columns plus a sort control. Sort by `Updated, oldest first` to find cards you have not touched in a while, or by `Created, oldest first` to find your oldest cards. Search and filters apply first, then the Table sort orders the visible cards. +Table includes `Start`, `Due`, `Updated`, and `Created` columns plus a sort control. Sort by `Updated, oldest first` to find cards you have not touched in a while, or by `Created, oldest first` to find your oldest cards. Search and filters apply first, then the Table sort orders the visible cards. ### Dated Views -Planner Calendar, This Week, Day, and Agenda place cards and due task items on dates across your open boards. Calendar and This Week use Monday-first weeks. +Planner Calendar, This Week, Day, and Agenda place cards and dated task items on dates across your open boards. Calendar and This Week use Monday-first weeks. Use it when you want to answer questions like: @@ -271,7 +279,7 @@ Planner cards show their source as `Board ยท List`, with that source pill tinted Planner defaults to all open boards. Use the scope toggle to narrow to the current board, or use the filter menu to choose a custom set of open boards. -Planner search matches card title, body, board name, and list name. Planner filters can narrow by date (`Today` or `Overdue`), completed-card visibility, and open board. When Planner is scoped to the current board only, the filter menu also includes that board's labels. +Planner search matches card title, body, board name, and list name. Planner filters can narrow by date (`Today`, `Overdue`, `Next 7 days`, `Next 14 days`, or `Next 30 days`), completed-card visibility, and open board. When Planner is scoped to the current board only, the filter menu also includes that board's labels. From Planner search, press `Enter` or `Arrow Down` to focus the first visible Planner card. Arrow keys move through the visible Planner cards, `Enter` or `Space` opens the focused card, and `Esc` returns focus to Planner search. @@ -371,7 +379,7 @@ The board `General` section lets you: The `Workflow` section controls which lists count as completed work for the current board. -Completed-list cards and checked-off task due markers keep their due dates, but Planner date views, Planner date filters, board date filters, and daily due reminders hide them by default so finished work does not look actionable. +Completed-list cards and checked-off task date markers keep their dates, but Planner date views, Planner date filters, board date filters, and daily due reminders hide them by default so finished work does not look actionable. Auto-detection is enabled by default. You can turn it off, manually choose completed lists, or uncheck an auto-detected list. diff --git a/index.html b/index.html index 5b89fd4..4380397 100644 --- a/index.html +++ b/index.html @@ -174,6 +174,7 @@ <h3 id="archiveRestoreTitle">Restore card</h3> <div id="modalAddCard" class="modal hidden" role="dialog" aria-modal="true" aria-label="Add card" aria-hidden="true"> <input id="userInput" type="text" placeholder="A small task" aria-label="Card title"> + <button id="newCardLabelButton" class="new-card-label-button" type="button" title="Set labels" aria-label="Set labels"><i data-feather="tag"></i><span class="new-card-label-button-text">Labels</span></button> <input id="hiddenListPath" type="hidden" value=""> <p class="new-card-modal-helper"><kbd>Shift</kbd> + <kbd>Enter</kbd> to create and open</p> <button id="btnAddCard" title="Add card">Add card</button> @@ -183,6 +184,7 @@ <h3 id="archiveRestoreTitle">Restore card</h3> <p><select id="userInputBoardPath" aria-label="Board"></select></p> <p><select id="userInputListPath" aria-label="List"></select></p> <p class="modalAddCardToListInputRow"><input id="userInputCardName" type="text" placeholder="Go fishing" aria-label="Card title"></p> + <button id="quickAddCardLabelButton" class="new-card-label-button" type="button" title="Set labels" aria-label="Set labels"><i data-feather="tag"></i><span class="new-card-label-button-text">Labels</span></button> <p class="new-card-modal-helper"><kbd>Shift</kbd> + <kbd>Enter</kbd> to create and open</p> <button id="btnAddCardToList" title="Add card to selected list">Add</button> </div> @@ -662,6 +664,7 @@ <h4>Tasks.md</h4> <div> <h2 id="cardEditorTitle" contenteditable="true" role="textbox" aria-label="Card title" aria-multiline="false" spellcheck="true"></h2> <div class="cardEditorMetadataButtons"> + <button type="button" id="cardEditorSetStartDateLink" title="Edit Start Date" aria-label="Edit start date"><i data-feather="play-circle"></i> <span id="cardEditorCardStartDateDisplay" title="This card's start date"></span></button> <button type="button" id="cardEditorSetDueDateLink" title="Edit Due Date" aria-label="Edit due date"><i data-feather="clock"></i> <span id="cardEditorCardDueDateDisplay" title="This card's due date"></span></button> <button type="button" id="cardEditorSetLabelsLink" title="Edit Labels" aria-label="Edit labels"><i data-feather="tag"></i> <span id="cardEditorCardLabels" title="This card's labels"></span></button> <button type="button" id="cardEditorLinkedObjectsLink" title="Linked objects" aria-label="Linked objects" aria-expanded="false"><i data-feather="paperclip"></i> <span id="cardEditorLinkedObjectsCount" class="card-editor-linked-objects-count" hidden></span></button> diff --git a/lib/cardFrontmatter.js b/lib/cardFrontmatter.js index 560f430..9ea1770 100644 --- a/lib/cardFrontmatter.js +++ b/lib/cardFrontmatter.js @@ -6,6 +6,7 @@ const LEGACY_DELIMITER = '**********'; const YAML_FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/; const ORDERED_KEYS = [ 'title', + 'start', 'due', 'labels', 'status', @@ -89,6 +90,10 @@ function orderFrontmatter(frontmatter) { title: frontmatter.title || '', }; + if (typeof frontmatter.start === 'string' && frontmatter.start.trim().length > 0) { + ordered.start = frontmatter.start.trim(); + } + if (typeof frontmatter.due === 'string' && frontmatter.due.trim().length > 0) { ordered.due = frontmatter.due.trim(); } @@ -96,7 +101,7 @@ function orderFrontmatter(frontmatter) { ordered.labels = Array.isArray(frontmatter.labels) ? frontmatter.labels : []; for (const key of ORDERED_KEYS) { - if (key === 'title' || key === 'due' || key === 'labels') { + if (key === 'title' || key === 'start' || key === 'due' || key === 'labels') { continue; } @@ -123,16 +128,45 @@ function normalizeFrontmatter(frontmatter = {}) { source.due = source['Due-date']; } + if (source.start == null) { + if (source.scheduled != null) { + source.start = source.scheduled; + } else if (source.startDate != null) { + source.start = source.startDate; + } else if (source.scheduledDate != null) { + source.start = source.scheduledDate; + } else if (source['Start-date'] != null) { + source.start = source['Start-date']; + } else if (source.Start != null) { + source.start = source.Start; + } else if (source.Scheduled != null) { + source.start = source.Scheduled; + } + } + if (source.labels == null && source.Labels != null) { source.labels = source.Labels; } delete source.Title; delete source['Due-date']; + delete source.scheduled; + delete source.startDate; + delete source.scheduledDate; + delete source['Start-date']; + delete source.Start; + delete source.Scheduled; delete source.Labels; source.title = source.title == null ? '' : String(source.title).trim(); + const start = toIsoDateString(source.start); + if (start === undefined) { + delete source.start; + } else { + source.start = start; + } + const due = toIsoDateString(source.due); if (due === undefined) { delete source.due; @@ -259,6 +293,10 @@ function frontmatterForWrite(frontmatter, filePath) { title: normalized.title, }; + if (normalized.start) { + ordered.start = normalized.start; + } + if (normalized.due) { ordered.due = normalized.due; } @@ -268,7 +306,7 @@ function frontmatterForWrite(frontmatter, filePath) { } for (const key of ORDERED_KEYS) { - if (key === 'title' || key === 'due' || key === 'labels') { + if (key === 'title' || key === 'start' || key === 'due' || key === 'labels') { continue; } diff --git a/lib/cliApp.js b/lib/cliApp.js index 111dd22..b5b25c1 100644 --- a/lib/cliApp.js +++ b/lib/cliApp.js @@ -193,7 +193,7 @@ function renderHelpText(commandName = 'signboard') { ' --include-archive', ' --json', '', - 'Card create/edit options:', + 'Card write options:', ' --body <text>', ' --body-file <path>', ' --from-card <ref> create only, copy card structure from this source card', @@ -203,11 +203,12 @@ function renderHelpText(commandName = 'signboard') { ' --append-body <text> edit only', ' --replace-section <heading> edit only, replace body under a Markdown heading', ' --insert-after-heading <heading> edit only, insert text below a Markdown heading', + ' --start <YYYY-MM-DD|none>', ' --due <YYYY-MM-DD|none>', - ' --label <ref> create only', + ' --label <ref> create/duplicate only', ' --set-label <ref> edit only, replaces labels', ' --add-label <ref> edit only', - ' --remove-label <ref> edit only', + ' --remove-label <ref> edit/create-from-card/duplicate only', ' --clear-labels edit/create-from-card/duplicate only', ' --move-to <list-ref> edit only', ' --dry-run preview card writes without changing files', @@ -380,6 +381,7 @@ function cardForJson(card) { listDirectoryName: card.listDirectoryName, listDisplayName: card.listDisplayName, title: card.title, + start: card.start || null, due: card.due || null, labels: card.labels, labelNames: card.labelNames || [], @@ -388,6 +390,8 @@ function cardForJson(card) { updatedAt: card.updatedAt || '', }, taskSummary: card.taskSummary, + taskStartDates: card.taskStartDates, + incompleteTaskStartDates: card.incompleteTaskStartDates, taskDueDates: card.taskDueDates, body: card.body, }; @@ -847,7 +851,7 @@ async function runCardsCommand(action, positionals, options, context) { if (action === 'create') { assertAllowedOptions( options, - ['list', 'title', 'from-card', 'from-list', 'body', 'body-file', 'due', 'label', 'remove-label', 'clear-labels', 'dry-run', 'json', 'board'], + ['list', 'title', 'from-card', 'from-list', 'body', 'body-file', 'start', 'due', 'label', 'remove-label', 'clear-labels', 'dry-run', 'json', 'board'], 'Usage: signboard cards create --list <list-ref> --title <title> [options]' ); assertNoExtraPositionals(positionals, 0, 'Usage: signboard cards create --list <list-ref> --title <title> [options]'); @@ -870,6 +874,7 @@ async function runCardsCommand(action, positionals, options, context) { targetListRef: listRef, ...(title ? { title } : { titlePrefix: '' }), ...(body !== undefined ? { body } : {}), + ...(Object.prototype.hasOwnProperty.call(options, 'start') ? { start: getOption(options, 'start') } : {}), ...(Object.prototype.hasOwnProperty.call(options, 'due') ? { due: getOption(options, 'due') } : {}), addLabelRefs: getOptionList(options, 'label'), removeLabelRefs: getOptionList(options, 'remove-label'), @@ -880,6 +885,7 @@ async function runCardsCommand(action, positionals, options, context) { listRef, title, body, + start: getOption(options, 'start'), due: getOption(options, 'due'), labelRefs: getOptionList(options, 'label'), dryRun: getOption(options, 'dry-run') === true, @@ -902,7 +908,7 @@ async function runCardsCommand(action, positionals, options, context) { if (action === 'duplicate') { assertAllowedOptions( options, - ['card', 'from-list', 'list', 'title', 'body', 'body-file', 'label', 'remove-label', 'clear-labels', 'dry-run', 'json', 'board'], + ['card', 'from-list', 'list', 'title', 'body', 'body-file', 'start', 'due', 'label', 'remove-label', 'clear-labels', 'dry-run', 'json', 'board'], 'Usage: signboard cards duplicate --card <card-ref> [--list <target-list-ref>] [options]' ); assertNoExtraPositionals(positionals, 0, 'Usage: signboard cards duplicate --card <card-ref> [--list <target-list-ref>] [options]'); @@ -918,6 +924,8 @@ async function runCardsCommand(action, positionals, options, context) { targetListRef: getOption(options, 'list'), ...(Object.prototype.hasOwnProperty.call(options, 'title') ? { title: getOption(options, 'title') } : {}), ...(body !== undefined ? { body } : {}), + ...(Object.prototype.hasOwnProperty.call(options, 'start') ? { start: getOption(options, 'start') } : {}), + ...(Object.prototype.hasOwnProperty.call(options, 'due') ? { due: getOption(options, 'due') } : {}), addLabelRefs: getOptionList(options, 'label'), removeLabelRefs: getOptionList(options, 'remove-label'), clearLabels: getOption(options, 'clear-labels') === true, @@ -983,7 +991,7 @@ async function runCardsCommand(action, positionals, options, context) { if (action === 'edit') { assertAllowedOptions( options, - ['card', 'list', 'title', 'body', 'body-file', 'append-body', 'replace-section', 'insert-after-heading', 'text', 'text-file', 'due', 'set-label', 'add-label', 'remove-label', 'clear-labels', 'move-to', 'dry-run', 'json', 'board'], + ['card', 'list', 'title', 'body', 'body-file', 'append-body', 'replace-section', 'insert-after-heading', 'text', 'text-file', 'start', 'due', 'set-label', 'add-label', 'remove-label', 'clear-labels', 'move-to', 'dry-run', 'json', 'board'], 'Usage: signboard cards edit --card <card-ref> [--list <list-ref>] [options]' ); assertNoExtraPositionals(positionals, 0, 'Usage: signboard cards edit --card <card-ref> [--list <list-ref>] [options]'); @@ -1043,6 +1051,10 @@ async function runCardsCommand(action, positionals, options, context) { editOptions.due = getOption(options, 'due'); } + if (Object.prototype.hasOwnProperty.call(options, 'start')) { + editOptions.start = getOption(options, 'start'); + } + const edited = await editCard(boardRoot, editOptions); if (getOption(options, 'json') === true) { diff --git a/lib/cliBoard.js b/lib/cliBoard.js index 69a0d24..11a226f 100644 --- a/lib/cliBoard.js +++ b/lib/cliBoard.js @@ -13,6 +13,8 @@ const { const { parseIsoDateStringToLocalDate, getTaskListSummary, + getTaskListStartDates, + getIncompleteTaskListStartDates, getTaskListDueDates, getIncompleteTaskListDueDates, } = require('./taskList'); @@ -522,6 +524,8 @@ async function loadCardRecord(boardRoot, listEntry, fileName) { fs.stat(cardPath), ]); const taskSummary = getTaskListSummary(card.body); + const taskStartDates = getTaskListStartDates(card.body); + const incompleteTaskStartDates = getIncompleteTaskListStartDates(card.body); const taskDueDates = getTaskListDueDates(card.body); const incompleteTaskDueDates = getIncompleteTaskListDueDates(card.body); const labels = Array.isArray(card.frontmatter.labels) @@ -539,11 +543,14 @@ async function loadCardRecord(boardRoot, listEntry, fileName) { cardId: getCardId(fileName), title: String(card.frontmatter.title || '').trim() || 'Untitled', body: card.body, + start: String(card.frontmatter.start || '').trim(), due: String(card.frontmatter.due || '').trim(), labels, frontmatter: card.frontmatter, timestamps, taskSummary, + taskStartDates, + incompleteTaskStartDates, taskDueDates, incompleteTaskDueDates, mtimeMs: stats.mtimeMs, @@ -752,19 +759,27 @@ async function resolveCard(boardRoot, options = {}) { ); } -function normalizeDueInput(dueValue) { - const normalized = String(dueValue || '').trim(); +function normalizeDateInput(dateValue, fieldLabel) { + const normalized = String(dateValue || '').trim(); if (!normalized || normalized.toLowerCase() === 'none') { return null; } if (!parseIsoDateStringToLocalDate(normalized)) { - throw new Error(`Due dates must use YYYY-MM-DD. Received: ${dueValue}`); + throw new Error(`${fieldLabel} dates must use YYYY-MM-DD. Received: ${dateValue}`); } return normalized; } +function normalizeDueInput(dueValue) { + return normalizeDateInput(dueValue, 'Due'); +} + +function normalizeStartInput(startValue) { + return normalizeDateInput(startValue, 'Start'); +} + async function createCard(boardRoot, options = {}) { const resolvedBoardRoot = await ensureBoardRoot(boardRoot); const listEntry = await resolveList(resolvedBoardRoot, options.listRef, { includeArchive: true }); @@ -776,12 +791,14 @@ async function createCard(boardRoot, options = {}) { const prefix = await nextCardPrefix(listEntry.path); const fileName = `${prefix}-${sanitizeCardSlug(title).slice(0, 25)}-${randomSuffix()}.md`; const filePath = path.join(listEntry.path, fileName); + const start = normalizeStartInput(options.start); const due = normalizeDueInput(options.due); const labelIds = options.labelRefs && options.labelRefs.length > 0 ? await resolveLabelIds(resolvedBoardRoot, options.labelRefs) : []; const frontmatter = prepareNewCardFrontmatter({ title, + start, due, labels: labelIds, }); @@ -799,6 +816,7 @@ async function createCard(boardRoot, options = {}) { filePath, cardId: getCardId(fileName), title, + start: start || '', due: due || '', labels: labelIds, labelNames: await labelNamesForIds(resolvedBoardRoot, labelIds), @@ -806,6 +824,8 @@ async function createCard(boardRoot, options = {}) { body, timestamps: resolveCardTimestamps(frontmatter), taskSummary: getTaskListSummary(body), + taskStartDates: getTaskListStartDates(body), + incompleteTaskStartDates: getIncompleteTaskListStartDates(body), taskDueDates: getTaskListDueDates(body), incompleteTaskDueDates: getIncompleteTaskListDueDates(body), }; @@ -887,6 +907,10 @@ async function duplicateCard(boardRoot, options = {}) { frontmatterSource.due = normalizeDueInput(options.due); } + if (Object.prototype.hasOwnProperty.call(options, 'start')) { + frontmatterSource.start = normalizeStartInput(options.start); + } + const frontmatter = prepareNewCardFrontmatter({ ...frontmatterSource, }); @@ -907,6 +931,7 @@ async function duplicateCard(boardRoot, options = {}) { filePath, cardId: getCardId(fileName), title, + start: String(frontmatter.start || ''), due: String(frontmatter.due || ''), labels, labelNames: await labelNamesForIds(resolvedBoardRoot, labels), @@ -914,6 +939,8 @@ async function duplicateCard(boardRoot, options = {}) { body, timestamps: resolveCardTimestamps(frontmatter), taskSummary: getTaskListSummary(body), + taskStartDates: getTaskListStartDates(body), + incompleteTaskStartDates: getIncompleteTaskListStartDates(body), taskDueDates: getTaskListDueDates(body), incompleteTaskDueDates: getIncompleteTaskListDueDates(body), }; @@ -976,6 +1003,10 @@ async function editCard(boardRoot, options = {}) { nextFrontmatter.due = normalizeDueInput(options.due); } + if (Object.prototype.hasOwnProperty.call(options, 'start')) { + nextFrontmatter.start = normalizeStartInput(options.start); + } + if (options.setLabelRefs && options.setLabelRefs.length > 0) { nextFrontmatter.labels = await resolveLabelIds(resolvedBoardRoot, options.setLabelRefs); } else { @@ -1019,6 +1050,7 @@ async function editCard(boardRoot, options = {}) { filePath: path.join(targetList.path, cardRecord.fileName), cardId: cardRecord.cardId, title: String(nextFrontmatter.title || '').trim() || cardRecord.title, + start: String(nextFrontmatter.start || ''), due: String(nextFrontmatter.due || ''), labels: Array.isArray(nextFrontmatter.labels) ? nextFrontmatter.labels : [], labelNames: await labelNamesForIds(resolvedBoardRoot, nextFrontmatter.labels), @@ -1030,6 +1062,8 @@ async function editCard(boardRoot, options = {}) { updatedAtSource: cardRecord.timestamps ? cardRecord.timestamps.updatedAtSource : '', }, taskSummary: getTaskListSummary(nextBody), + taskStartDates: getTaskListStartDates(nextBody), + incompleteTaskStartDates: getIncompleteTaskListStartDates(nextBody), taskDueDates: getTaskListDueDates(nextBody), incompleteTaskDueDates: getIncompleteTaskListDueDates(nextBody), wouldMove: Boolean(options.moveToListRef), diff --git a/lib/importers/obsidian.js b/lib/importers/obsidian.js index bd50653..2e5fa90 100644 --- a/lib/importers/obsidian.js +++ b/lib/importers/obsidian.js @@ -31,6 +31,12 @@ const CARD_BOARD_DUE_PATTERNS = [ { type: 'tasks', pattern: /๐Ÿ“…\s*(\d{4}-\d{2}-\d{2})/i }, { type: 'kanban', pattern: /@\{(\d{4}-\d{2}-\d{2})\}/i }, ]; +const CARD_BOARD_START_PATTERNS = [ + { type: 'cardboard', pattern: /@(?:start|scheduled)\(([^)]+)\)/i }, + { type: 'dataview', pattern: /\[(?:start|scheduled)::\s*([^\]]+)\]/i }, + { type: 'tasks-start', pattern: /๐Ÿ›ซ\s*(\d{4}-\d{2}-\d{2})/i }, + { type: 'tasks-scheduled', pattern: /โณ\s*(\d{4}-\d{2}-\d{2})/i }, +]; const CARD_BOARD_COMPLETED_PATTERNS = [ /@completed\(([^)]+)\)/i, /โœ…\s*(\d{4}-\d{2}-\d{2}(?:T[0-9:.+-Z]+)?)/i, @@ -116,6 +122,34 @@ function parseDueFromText(text) { return null; } +function parseStartFromText(text) { + const source = String(text || ''); + + for (const matcher of CARD_BOARD_START_PATTERNS) { + const match = source.match(matcher.pattern); + if (!match) { + continue; + } + + const rawValue = String(match[1] || '').trim(); + if (!rawValue || rawValue.toLowerCase() === 'none') { + return { + type: matcher.type, + raw: rawValue, + start: '', + }; + } + + return { + type: matcher.type, + raw: rawValue, + start: normalizeIsoDateFromValue(rawValue), + }; + } + + return null; +} + function parseCompletedFromText(text) { const source = String(text || ''); for (const pattern of CARD_BOARD_COMPLETED_PATTERNS) { @@ -143,8 +177,11 @@ function stripConsumedTaskMetadata(text) { .replace(TASK_LIST_KANBAN_COLUMN_PATTERN, '') .replace(/@\{(\d{4}-\d{2}-\d{2})\}/g, '') .replace(/@due\(([^)]+)\)/gi, '') + .replace(/@(?:start|scheduled)\(([^)]+)\)/gi, '') .replace(/\[due::\s*[^\]]+\]/gi, '') + .replace(/\[(?:start|scheduled)::\s*[^\]]+\]/gi, '') .replace(/๐Ÿ“…\s*\d{4}-\d{2}-\d{2}/g, '') + .replace(/[๐Ÿ›ซโณ]\s*\d{4}-\d{2}-\d{2}/g, '') .replace(/@completed\(([^)]+)\)/gi, '') .replace(/โœ…\s*\d{4}-\d{2}-\d{2}(?:T[0-9:.+-Z]+)?/g, '') .replace(/(^|\s)#([A-Za-z0-9/_-]+)/g, '$1') @@ -174,7 +211,11 @@ function parseMarkdownTaskBlocks(rawContent, filePath) { ]); currentTask.columnTags = dedupeStrings(extractTaskColumnTags(currentTask.rawLine)); currentTask.dueInfo = parseDueFromText(currentTask.rawLine); + currentTask.startInfo = parseStartFromText(currentTask.rawLine); currentTask.completedInfo = parseCompletedFromText(currentTask.rawLine); + currentTask.start = currentTask.startInfo && currentTask.startInfo.start + ? currentTask.startInfo.start + : ''; currentTask.due = currentTask.dueInfo && currentTask.dueInfo.due ? currentTask.dueInfo.due : derivedDate; @@ -313,9 +354,11 @@ function parseObsidianKanbanColumns(rawContent, filePath) { filePath, cards: column.cards.map((card) => { const dueInfo = parseDueFromText(card.rawLine); + const startInfo = parseStartFromText(card.rawLine); const tags = collectInlineTags(card.rawLine); return { ...card, + start: startInfo && startInfo.start ? startInfo.start : '', due: dueInfo && dueInfo.due ? dueInfo.due : '', tags, cleanedTitle: stripConsumedTaskMetadata(card.rawLine), @@ -350,6 +393,7 @@ async function importObsidianKanbanBoard(context, board, shouldPrefix) { await createCard(context, targetList, { title: card.cleanedTitle || 'Untitled', + start: card.start, due: card.due, labels: labelIds, body, @@ -571,6 +615,7 @@ function buildTaskMetadataSection(task, metadata = {}) { 'Source line': task.lineNumber != null ? String(task.lineNumber) : '', 'Original task line': task.rawLine, 'Completed in source': task.checked ? 'Yes' : '', + 'Original start expression': task.startInfo?.raw || '', 'Original due expression': task.dueInfo?.raw || '', }); } @@ -664,6 +709,7 @@ async function importCardBoardVault(context, source, shouldPrefix) { await createCard(context, listEntry, { title: task.cleanedTitle || 'Untitled', + start: task.start, due: task.due, labels: labelIds, body, @@ -751,6 +797,7 @@ async function importGenericTaskScope(context, scope, shouldPrefix) { await createCard(context, listEntry, { title: task.cleanedTitle || 'Untitled', + start: task.start, due: task.due, labels: labelIds, body, diff --git a/lib/importers/shared.js b/lib/importers/shared.js index 318b3f5..0285817 100644 --- a/lib/importers/shared.js +++ b/lib/importers/shared.js @@ -472,6 +472,7 @@ async function createCard(context, listEntry, card = {}) { const filePath = path.join(targetList.path, fileName); const normalizedFrontmatter = prepareNewCardFrontmatter({ title, + start: normalizeIsoDateFromValue(card.start), due: normalizeIsoDateFromValue(card.due), labels: Array.isArray(card.labels) ? card.labels.filter(Boolean) : [], }); diff --git a/lib/importers/trello.js b/lib/importers/trello.js index b2ff67d..b8998e8 100644 --- a/lib/importers/trello.js +++ b/lib/importers/trello.js @@ -266,6 +266,7 @@ async function importTrello(options = {}) { await createCard(context, targetList, { title: card?.name || 'Untitled', + start: normalizeIsoDateFromValue(card?.start), due: normalizeIsoDateFromValue(card?.due), labels: cardLabelIds, body, diff --git a/lib/mcpServer.js b/lib/mcpServer.js index ee3ac33..3b123eb 100644 --- a/lib/mcpServer.js +++ b/lib/mcpServer.js @@ -182,6 +182,7 @@ const INTERNAL_TOOL_DEFINITIONS = [ listName: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' }, + start: { type: ['string', 'null'], description: 'Optional start/scheduled date. Use YYYY-MM-DD when possible.' }, due: { type: ['string', 'null'], description: 'Optional due date. Use YYYY-MM-DD when possible.' }, labels: { type: 'array', @@ -205,6 +206,7 @@ const INTERNAL_TOOL_DEFINITIONS = [ cardFile: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' }, + start: { type: ['string', 'null'] }, due: { type: ['string', 'null'] }, labels: { type: 'array', items: { type: 'string' } }, addLabelIds: { type: 'array', items: { type: 'string' } }, @@ -472,7 +474,7 @@ class JsonRpcError extends Error { } const TASK_LIST_ITEM_PATTERN = /^(\s*[-*+]\s*\[([\sxXโœ“โœ”]*)\]\s*)(.*)$/; -const TASK_DUE_MARKER_PATTERN = /^\(due:\s*(\d{4}-\d{2}-\d{2})\)\s*/i; +const TASK_DATE_MARKER_PATTERN = /^\((due|start|scheduled):\s*(\d{4}-\d{2}-\d{2})\)\s*/i; function parseIsoDateStringToLocalDate(dateValue) { const normalized = String(dateValue || '').trim(); @@ -507,12 +509,17 @@ function normalizeTaskDueDateValue(dateValue) { return parseIsoDateStringToLocalDate(normalized) ? normalized : ''; } +function normalizeTaskStartDateValue(dateValue) { + return normalizeTaskDueDateValue(dateValue); +} + function summarizeTaskList(bodyValue) { const body = String(bodyValue || ''); const lines = body.split(/\r?\n/); let total = 0; let completed = 0; + const startSet = new Set(); const dueSet = new Set(); for (const lineValue of lines) { @@ -529,15 +536,25 @@ function summarizeTaskList(bodyValue) { completed += 1; } - const content = String(match[3] || '').replace(/^\s+/, ''); - const dueMatch = content.match(TASK_DUE_MARKER_PATTERN); - if (!dueMatch) { - continue; - } + let content = String(match[3] || '').replace(/^\s+/, ''); + while (content) { + const dateMatch = content.match(TASK_DATE_MARKER_PATTERN); + if (!dateMatch) { + break; + } - const dueDate = normalizeTaskDueDateValue(dueMatch[1]); - if (dueDate) { - dueSet.add(dueDate); + const markerName = String(dateMatch[1] || '').trim().toLowerCase(); + const dateValue = markerName === 'due' + ? normalizeTaskDueDateValue(dateMatch[2]) + : normalizeTaskStartDateValue(dateMatch[2]); + if (dateValue) { + if (markerName === 'due') { + dueSet.add(dateValue); + } else { + startSet.add(dateValue); + } + } + content = content.slice(dateMatch[0].length); } } @@ -547,6 +564,7 @@ function summarizeTaskList(bodyValue) { completed, remaining: Math.max(0, total - completed), }, + taskStartDates: [...startSet].sort(), taskDueDates: [...dueSet].sort(), }; } @@ -1177,6 +1195,14 @@ async function handleToolCall(config, name, args = {}) { title, }; + if (Object.prototype.hasOwnProperty.call(args, 'start')) { + if (args.start == null || String(args.start).trim() === '') { + frontmatter.start = undefined; + } else { + frontmatter.start = String(args.start).trim(); + } + } + if (Object.prototype.hasOwnProperty.call(args, 'due')) { if (args.due == null || String(args.due).trim() === '') { frontmatter.due = undefined; @@ -1258,6 +1284,14 @@ async function handleToolCall(config, name, args = {}) { } } + if (Object.prototype.hasOwnProperty.call(args, 'start')) { + if (args.start == null || String(args.start).trim() === '') { + delete nextFrontmatter.start; + } else { + nextFrontmatter.start = String(args.start).trim(); + } + } + if (Object.prototype.hasOwnProperty.call(args, 'labels')) { if (!Array.isArray(args.labels)) { throw new Error('labels must be an array of strings when provided.'); diff --git a/lib/obsidianIntegration.js b/lib/obsidianIntegration.js index 2fd725a..f63a72d 100644 --- a/lib/obsidianIntegration.js +++ b/lib/obsidianIntegration.js @@ -217,6 +217,7 @@ function buildGeneratedBaseYaml(boardRoot, vaultRoot = '') { title: { displayName: 'Title' }, signboard_board: { displayName: 'Board' }, status: { displayName: 'Status' }, + start: { displayName: 'Start' }, due: { displayName: 'Due' }, labels: { displayName: 'Labels' }, related: { displayName: 'Related' }, @@ -232,6 +233,7 @@ function buildGeneratedBaseYaml(boardRoot, vaultRoot = '') { order: [ 'title', 'status', + 'start', 'due', 'labels', 'related', @@ -247,6 +249,7 @@ function buildGeneratedBaseYaml(boardRoot, vaultRoot = '') { order: [ 'title', 'status', + 'start', 'due', 'labels', 'file.name', diff --git a/lib/taskList.js b/lib/taskList.js index b19d729..8402610 100644 --- a/lib/taskList.js +++ b/lib/taskList.js @@ -1,6 +1,6 @@ const TASK_LIST_ITEM_PATTERN = /^(\s*[-*+]\s*\[([\sxXโœ“โœ”]*)\]\s*)(.*)$/; -const TASK_DUE_MARKER_PATTERN = /^\(due:\s*(\d{4}-\d{2}-\d{2})\)\s*/i; -const TASK_DUE_MARKER_LOOSE_PATTERN = /^\(due:\s*([^)]+)\)\s*/i; +const TASK_DATE_MARKER_PATTERN = /^\((due|start|scheduled):\s*(\d{4}-\d{2}-\d{2})\)\s*/i; +const TASK_DATE_MARKER_LOOSE_PATTERN = /^\((due|start|scheduled):\s*([^)]+)\)\s*/i; function parseIsoDateStringToLocalDate(dateValue) { const normalized = String(dateValue || '').trim(); @@ -26,7 +26,7 @@ function parseIsoDateStringToLocalDate(dateValue) { return parsed; } -function normalizeTaskDueDateValue(dateValue) { +function normalizeTaskDateValue(dateValue) { const normalized = String(dateValue || '').trim(); if (!normalized) { return ''; @@ -35,6 +35,48 @@ function normalizeTaskDueDateValue(dateValue) { return parseIsoDateStringToLocalDate(normalized) ? normalized : ''; } +function normalizeTaskDueDateValue(dateValue) { + return normalizeTaskDateValue(dateValue); +} + +function normalizeTaskStartDateValue(dateValue) { + return normalizeTaskDateValue(dateValue); +} + +function parseTaskDateMarkers(contentValue) { + let remaining = String(contentValue || '').replace(/^\s+/, ''); + let due = ''; + let start = ''; + let consumedAnyMarker = false; + + while (remaining) { + const strictMatch = remaining.match(TASK_DATE_MARKER_PATTERN); + const looseMatch = remaining.match(TASK_DATE_MARKER_LOOSE_PATTERN); + const markerMatch = strictMatch || looseMatch; + if (!markerMatch) { + break; + } + + consumedAnyMarker = true; + const markerName = String(markerMatch[1] || '').trim().toLowerCase(); + const markerValue = strictMatch ? normalizeTaskDateValue(markerMatch[2]) : ''; + if (markerValue) { + if (markerName === 'due') { + due = markerValue; + } else { + start = markerValue; + } + } + remaining = remaining.slice(markerMatch[0].length); + } + + return { + due, + start, + contentWithoutDateMarkers: consumedAnyMarker ? remaining : String(contentValue || '').replace(/^\s+/, ''), + }; +} + function parseTaskListItemLine(lineValue) { const normalizedLine = String(lineValue || ''); const match = normalizedLine.match(TASK_LIST_ITEM_PATTERN); @@ -53,24 +95,16 @@ function parseTaskListItemLine(lineValue) { const content = String(match[3] || ''); const normalizedContent = content.replace(/^\s+/, ''); - let contentWithoutDue = normalizedContent; - const looseMarkerMatch = normalizedContent.match(TASK_DUE_MARKER_LOOSE_PATTERN); - if (looseMarkerMatch) { - contentWithoutDue = normalizedContent.slice(looseMarkerMatch[0].length); - } - - let due = ''; - const strictMarkerMatch = normalizedContent.match(TASK_DUE_MARKER_PATTERN); - if (strictMarkerMatch) { - due = normalizeTaskDueDateValue(strictMarkerMatch[1]); - } + const dateMarkers = parseTaskDateMarkers(normalizedContent); return { prefix, isCompleted, content, - contentWithoutDue, - due, + contentWithoutDue: dateMarkers.contentWithoutDateMarkers, + contentWithoutDateMarkers: dateMarkers.contentWithoutDateMarkers, + due: dateMarkers.due, + start: dateMarkers.start, }; } @@ -155,12 +189,42 @@ function getIncompleteTaskListDueDates(bodyValue) { return [...dueSet].sort(); } +function getTaskListStartDates(bodyValue) { + const items = parseTaskListItems(bodyValue); + const startSet = new Set(); + for (const item of items) { + if (item.start) { + startSet.add(item.start); + } + } + + return [...startSet].sort(); +} + +function getIncompleteTaskListStartDates(bodyValue) { + const items = parseTaskListItems(bodyValue); + const startSet = new Set(); + for (const item of items) { + if (item.isCompleted || !item.start) { + continue; + } + + startSet.add(item.start); + } + + return [...startSet].sort(); +} + module.exports = { parseIsoDateStringToLocalDate, + normalizeTaskDateValue, normalizeTaskDueDateValue, + normalizeTaskStartDateValue, parseTaskListItemLine, parseTaskListItems, getTaskListSummary, getTaskListDueDates, getIncompleteTaskListDueDates, + getTaskListStartDates, + getIncompleteTaskListStartDates, }; diff --git a/main.js b/main.js index 7d256e5..9cb1756 100644 --- a/main.js +++ b/main.js @@ -3991,10 +3991,15 @@ ipcMain.handle('board-call', async (event, payload = {}) => { case 'createCard': { const filePath = requireWritablePath(event.sender, args[0], { allowTrusted: true }); const content = String(args[1] || ''); + const createOptions = args[2] && typeof args[2] === 'object' && !Array.isArray(args[2]) ? args[2] : {}; + const initialFrontmatter = createOptions.frontmatter && typeof createOptions.frontmatter === 'object' && !Array.isArray(createOptions.frontmatter) + ? createOptions.frontmatter + : {}; const lines = content.split(/\r?\n/); const title = (lines.shift() || '').trim(); const body = lines.join('\n').replace(/^\n+/, ''); const frontmatter = normalizeCardFrontmatterForBoardPath(event.sender, filePath, prepareNewCardFrontmatter({ + ...initialFrontmatter, title: title || 'Untitled', })); diff --git a/preload.js b/preload.js index 3e01335..4db83a4 100644 --- a/preload.js +++ b/preload.js @@ -87,7 +87,7 @@ contextBridge.exposeInMainWorld('board', { invokeBoard('updateBoardThemeOverrides', boardRoot, themeOverrides), updateBoardSettings: async (boardRoot, partialSettings) => invokeBoard('updateBoardSettings', boardRoot, partialSettings), - createCard: async (filePath, content) => invokeBoard('createCard', filePath, content), + createCard: async (filePath, content, options) => invokeBoard('createCard', filePath, content, options), generateObsidianBase: async (boardRoot) => invokeBoard('generateObsidianBase', boardRoot), openObsidianBase: async (boardRoot) => invokeBoard('openObsidianBase', boardRoot), createLinkedObsidianNote: async (boardRoot, filePath) => diff --git a/readme.md b/readme.md index 503de48..1eb9692 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +17,7 @@ Signboard is free for personal use. If you are using Signboard for your work it - ๐Ÿ–Œ๏ธ Color scheme per board (several to choose from!) - ๐ŸŒ™ Light and dark mode variants for all color schemes - ๐Ÿท Custom labels per board -- ๐Ÿ—“ Card due dates and task list item due dates +- ๐Ÿ—“ Card start/due dates and task list item start/due dates - ๐Ÿ“‹ Kanban and Table board views, including card age columns and sorting in Table - ๐Ÿ—‚ Planner overlay for actionable dated work across open boards - ๐Ÿ“† Optional local External Published Calendar feed for calendar app subscriptions @@ -139,7 +139,7 @@ signboard lists rename "Waiting" "Blocked" signboard cards --due next:7 signboard cards "To do" signboard cards --label Urgent --search launch -signboard cards create --list "To do" --title "Ship release notes" --due 2026-03-20 +signboard cards create --list "To do" --title "Ship release notes" --start 2026-03-18 --due 2026-03-20 signboard cards edit --card ab123 --due none --move-to Doing signboard cards duplicate --card ab123 --list Leads --remove-label Template --dry-run --json signboard cards create --from-card ab123 --list Leads --title "New lead" @@ -192,7 +192,9 @@ Example task checklist syntax: ```md - [ ] Draft update +- [ ] (start: 2026-03-18) Outline proposal - [x ] (due: 2026-03-20) Send proposal +- [ ] (scheduled: 2026-03-21) Follow up - [ X] Confirm scope - [ x ] Share notes ``` diff --git a/scripts/test-board-views.js b/scripts/test-board-views.js index 628ca74..88cd823 100644 --- a/scripts/test-board-views.js +++ b/scripts/test-board-views.js @@ -455,6 +455,11 @@ async function run() { assert.strictEqual(filterButton.classList.contains('is-active'), true); assert.strictEqual(filterButton.getAttribute('data-active-filters'), '2'); + filterState.filterIds = []; + filterState.activeDateFilter = 'next:7'; + context.renderBoardLabelFilterButton(); + assert.strictEqual(filterButton.getAttribute('aria-label'), 'Filter cards: Next 7 days'); + assert.deepStrictEqual( toPlain(context.getCardFilterDueDates('2026-03-10', ['2026-03-09', '2026-03-10'])), ['2026-03-09', '2026-03-10'], @@ -473,6 +478,17 @@ async function run() { assert.strictEqual(context.cardMatchesBoardLabelFilter([], ['2026-03-10']), false); assert.strictEqual(context.doesBoardDateFilterMatchDueDate('2026-03-09'), true); assert.strictEqual(context.doesBoardDateFilterMatchDueDate('2026-03-10'), false); + filterState.activeDateFilter = 'next:7'; + assert.strictEqual(context.doesBoardDateFilterMatchDueDate('2026-03-10'), true); + assert.strictEqual(context.doesBoardDateFilterMatchDueDate('2026-03-17'), true); + assert.strictEqual(context.doesBoardDateFilterMatchDueDate('2026-03-18'), false); + assert.strictEqual(context.cardMatchesBoardLabelFilter([], ['2026-03-12']), true); + assert.deepStrictEqual( + toPlain(context.getActiveBoardFilterDueDates('', ['2026-03-12'], [])), + [], + 'expected next-range active-filter due dates to ignore completed task dates', + ); + filterState.activeDateFilter = 'overdue'; assert.strictEqual( context.cardMatchesBoardLabelFilter([], ['2026-03-09'], []), false, @@ -513,7 +529,7 @@ async function run() { const tableHeader = context.createBoardTableHeader(); assert.deepStrictEqual( toPlain(tableHeader.children[0].children.map((headerCell) => headerCell.textContent)), - ['Due', 'Updated', 'Created', 'Tasks', 'Links', 'Card', 'List', 'Labels'], + ['Start', 'Due', 'Updated', 'Created', 'Tasks', 'Links', 'Card', 'List', 'Labels'], 'expected table columns to keep the configured order', ); @@ -578,6 +594,7 @@ async function run() { assert(filterPopover.textContent.includes('Today'), 'expected Today row in filter popover'); assert(filterPopover.textContent.includes('Overdue'), 'expected Overdue row in filter popover'); + assert(filterPopover.textContent.includes('Next 7 days'), 'expected Next 7 days row in filter popover'); assert(findFirstByClass(filterPopover, 'label-popover-separator'), 'expected separator in filter popover'); assert(findFirstByClass(filterPopover, 'label-popover-labels-scroll'), 'expected scroll container for long label lists'); @@ -588,6 +605,38 @@ async function run() { const todayTaskBody = '- [ ] (due: 2026-03-10) Prep launch'; const todayTaskItems = context.parseTaskListItems(todayTaskBody); const entries = [ + { + cardPath: '/tmp/task-starts-soon.md', + listName: '003-In Progress-abc12', + listDisplayName: 'In Progress', + title: 'Task starts soon', + start: '', + due: '', + labels: [], + body: '- [ ] (start: 2026-03-12) Draft outline', + taskSummary: { total: 1, completed: 0, remaining: 1 }, + taskItems: context.parseTaskListItems('- [ ] (start: 2026-03-12) Draft outline'), + taskStartDates: ['2026-03-12'], + incompleteTaskStartDates: ['2026-03-12'], + taskDueDates: [], + incompleteTaskDueDates: [], + }, + { + cardPath: '/tmp/card-starts-soon.md', + listName: '001-Backlog-abc12', + listDisplayName: 'Backlog', + title: 'Card starts soon', + start: '2026-03-12', + due: '', + labels: [], + body: 'Body', + taskSummary: { total: 0, completed: 0, remaining: 0 }, + taskItems: [], + taskStartDates: [], + incompleteTaskStartDates: [], + taskDueDates: [], + incompleteTaskDueDates: [], + }, { cardPath: '/tmp/task-today.md', listName: '003-In Progress-abc12', @@ -683,6 +732,15 @@ async function run() { assert.strictEqual(overdueWeekEntries[0].temporalReason, 'card'); assert.strictEqual(overdueWeekBuckets.has('2026-03-10'), false); + filterState.activeDateFilter = 'next:7'; + const nextCalendarBuckets = context.buildCalendarCardBuckets(entries, new context.Date(2026, 2, 1)); + const nextStartEntries = nextCalendarBuckets.get('2026-03-12') || []; + assert.deepStrictEqual( + toPlain(nextStartEntries.map((entry) => entry.temporalReason).sort()), + ['card-start', 'task-start'], + 'expected next-range calendar view to include card and task start placements', + ); + const tableLists = [ { listName: '000-To-do-stock', diff --git a/scripts/test-cli.js b/scripts/test-cli.js index 9b3dabb..6765f27 100644 --- a/scripts/test-cli.js +++ b/scripts/test-cli.js @@ -208,6 +208,7 @@ async function createImportFixtures(root) { idList: 'trello-list-1', name: 'Imported pitch', desc: 'Imported from Trello.', + start: '2026-03-21', due: '2026-03-22', closed: false, pos: 1, @@ -226,7 +227,7 @@ async function createImportFixtures(root) { '---', '', '## Inbox', - '- [ ] Draft intro @{2026-03-24} #Writing', + '- [ ] Draft intro @start(2026-03-21) @{2026-03-24} #Writing', '', '## Done', '- [x] Sent final #Done', @@ -463,6 +464,10 @@ async function main() { 'Lead from template', '--remove-label', 'Template', + '--start', + '2026-03-25', + '--due', + '2026-03-26', '--dry-run', '--json', ], env).stdout @@ -471,6 +476,8 @@ async function main() { assert.strictEqual(duplicatePreview.operation, 'duplicate-card'); assert.strictEqual(duplicatePreview.listDisplayName, 'Waiting'); assert.strictEqual(duplicatePreview.title, 'Lead from template'); + assert.strictEqual(duplicatePreview.start, '2026-03-25'); + assert.strictEqual(duplicatePreview.due, '2026-03-26'); assert.ok(duplicatePreview.labels.includes('urgent')); assert.ok(!duplicatePreview.labels.includes('template')); assert.ok(duplicatePreview.timestamps.createdAt); @@ -491,11 +498,17 @@ async function main() { 'Lead from template', '--remove-label', 'Template', + '--start', + '2026-03-25', + '--due', + '2026-03-26', '--json', ], env).stdout ); assert.strictEqual(duplicatedCard.listDisplayName, 'Waiting'); assert.strictEqual(duplicatedCard.title, 'Lead from template'); + assert.strictEqual(duplicatedCard.start, '2026-03-25'); + assert.strictEqual(duplicatedCard.due, '2026-03-26'); assert.ok(duplicatedCard.labels.includes('urgent')); assert.ok(!duplicatedCard.labels.includes('template')); assert.strictEqual(duplicatedCard.taskSummary.total, 1); @@ -637,6 +650,8 @@ async function main() { 'Needs approval', '--body', 'Waiting on leadership', + '--start', + '2026-03-18', '--due', '2026-03-20', '--label', @@ -645,6 +660,7 @@ async function main() { ], env).stdout ); assert.strictEqual(createdCard.listDisplayName, 'Waiting'); + assert.strictEqual(createdCard.start, '2026-03-18'); assert.strictEqual(createdCard.due, '2026-03-20'); assert.deepStrictEqual(createdCard.labels, ['client']); @@ -656,6 +672,8 @@ async function main() { createdCard.id, '--due', 'none', + '--start', + 'none', '--add-label', 'Urgent', '--move-to', @@ -667,6 +685,7 @@ async function main() { ); assert.strictEqual(editedCard.listDisplayName, 'Doing'); assert.ok(editedCard.fileName.startsWith('000-')); + assert.strictEqual(editedCard.start, null); assert.strictEqual(editedCard.due, null); assert.deepStrictEqual(editedCard.labels.sort(), ['client', 'urgent']); assert.ok(editedCard.body.includes('Escalated yesterday.')); @@ -683,6 +702,7 @@ async function main() { ); assert.strictEqual(readCard.title, 'Needs approval'); assert.strictEqual(readCard.listDisplayName, 'Doing'); + assert.strictEqual(readCard.start, null); assert.ok(readCard.timestamps.createdAt); assert.ok(readCard.timestamps.updatedAt); @@ -801,6 +821,7 @@ async function main() { ], env).stdout ); assert.strictEqual(trelloCards.length, 1); + assert.strictEqual(trelloCards[0].start, '2026-03-21'); assert.strictEqual(trelloCards[0].due, '2026-03-22'); const obsidianImport = JSON.parse( @@ -825,6 +846,7 @@ async function main() { ], env).stdout ); assert.strictEqual(obsidianCards.length, 1); + assert.strictEqual(obsidianCards[0].start, '2026-03-21'); assert.strictEqual(obsidianCards[0].due, '2026-03-24'); assert.ok(obsidianCards[0].labelNames.includes('Writing')); diff --git a/scripts/test-frontmatter.js b/scripts/test-frontmatter.js index 424d65a..c54d095 100644 --- a/scripts/test-frontmatter.js +++ b/scripts/test-frontmatter.js @@ -41,6 +41,7 @@ async function run() { const roundTripContent = [ '---', 'title: Keep body exact', + 'scheduled: 2026-03-28', 'due: 2026-04-01', 'labels:', ' - alpha', @@ -60,6 +61,7 @@ async function run() { await fs.writeFile(roundTripPath, roundTripContent, 'utf8'); const firstRead = await readCard(roundTripPath); + assert.strictEqual(firstRead.frontmatter.start, '2026-03-28'); const bodyBefore = firstRead.body; await writeCard(roundTripPath, firstRead); @@ -71,6 +73,7 @@ async function run() { await updateFrontmatter(roundTripPath, { due: '2026-05-20' }); const updated = await readCard(roundTripPath); assert.strictEqual(updated.frontmatter.title, 'Keep body exact'); + assert.strictEqual(updated.frontmatter.start, '2026-03-28'); assert.strictEqual(updated.frontmatter.due, '2026-05-20'); assert.strictEqual(updated.frontmatter.owner, 'Colin'); assert.deepStrictEqual(updated.frontmatter.labels, ['alpha']); @@ -90,13 +93,14 @@ async function run() { assert.deepStrictEqual(plainCard.frontmatter.labels, []); assert.strictEqual(plainCard.body, plainContent); - // 5) due should not be written when null/empty + // 5) start and due should not be written when null/empty const noDuePath = path.join(tmpDir, '020-no-due.md'); await writeCard(noDuePath, { - frontmatter: { title: 'No Due', due: null, labels: [] }, + frontmatter: { title: 'No Due', start: '', due: null, labels: [] }, body: 'Body', }); const noDueRaw = await fs.readFile(noDuePath, 'utf8'); + assert(!noDueRaw.includes('\nstart:'), 'start field should be omitted when null/empty'); assert(!noDueRaw.includes('\ndue:'), 'due field should be omitted when null/empty'); console.log('Frontmatter tests passed.'); diff --git a/scripts/test-import-obsidian.js b/scripts/test-import-obsidian.js index 107a9c1..321ee67 100644 --- a/scripts/test-import-obsidian.js +++ b/scripts/test-import-obsidian.js @@ -76,7 +76,7 @@ async function run() { }, null, 2), 'utf8'); await fs.writeFile(path.join(cardBoardVault, 'daily.md'), [ - `- [ ] Ship to beta @due(${todayIso}) #launch`, + `- [ ] Ship to beta @scheduled(${todayIso}) @due(${todayIso}) #launch`, '- [ ] Write changelog', `- [x] Sent announcement @due(${todayIso}) #launch @completed(${todayIso}T09:00:00)`, ].join('\n'), 'utf8'); @@ -125,6 +125,7 @@ async function run() { const todayFiles = await listMarkdownFiles(path.join(boardRoot, cardBoardTodayDir)); const todayCard = await cardFrontmatter.readCard(path.join(boardRoot, cardBoardTodayDir, todayFiles[0])); assert.strictEqual(todayCard.frontmatter.title, 'Ship to beta'); + assert.strictEqual(todayCard.frontmatter.start, todayIso); assert.strictEqual(todayCard.frontmatter.due, todayIso); const cardBoardCompletedDir = directoryNames.find((name) => getListDisplayName(name) === 'Daily Board - Completed'); diff --git a/scripts/test-import-trello.js b/scripts/test-import-trello.js index 6e7900b..b3e4765 100644 --- a/scripts/test-import-trello.js +++ b/scripts/test-import-trello.js @@ -147,6 +147,7 @@ async function run() { assert.strictEqual(backlogFiles.length, 1); const openCard = await cardFrontmatter.readCard(path.join(boardRoot, backlogDirectory, backlogFiles[0])); assert.strictEqual(openCard.frontmatter.title, 'Open Trello Card'); + assert.strictEqual(openCard.frontmatter.start, '2026-04-01'); assert.strictEqual(openCard.frontmatter.due, '2026-04-02'); assert.deepStrictEqual(openCard.frontmatter.labels.sort(), ['quoted-existing', settings.labels.find((label) => label.name === 'Trello Blue').id].sort()); assert(openCard.body.includes('Body from Trello')); diff --git a/scripts/test-mcp-server.js b/scripts/test-mcp-server.js index 2ccb5c9..cb9d79b 100644 --- a/scripts/test-mcp-server.js +++ b/scripts/test-mcp-server.js @@ -109,12 +109,13 @@ async function createFixtureBoard() { [ '---', 'title: Template Card', + 'start: 2026-03-18', 'labels:', ' - template', '---', 'Customer details go here.', - '- [ ] Initial outreach', - '- [x ] (due: 2026-03-20) Send proposal', + '- [ ] (start: 2026-03-18) Initial outreach', + '- [x ] (start: 2026-03-19) (due: 2026-03-20) Send proposal', '- [ X] Confirm timeline', '', ].join('\n'), @@ -151,6 +152,7 @@ async function createFixtureBoard() { idList: 'mcp-trello-list-1', name: 'Imported via MCP', desc: 'Created through signboard.import_trello.', + start: '2026-03-28', due: '2026-03-30', closed: false, pos: 1, @@ -170,7 +172,7 @@ async function createFixtureBoard() { '---', '', '## Inbox', - '- [ ] MCP draft @{2026-03-29} #MCP', + '- [ ] MCP draft @scheduled(2026-03-27) @{2026-03-29} #MCP', '', '## Review', '- [ ] MCP review', @@ -532,6 +534,12 @@ async function runForTransport(transportMode, fixture) { if (!Array.isArray(duplicateOutput.taskDueDates) || !duplicateOutput.taskDueDates.includes('2026-03-20')) { throw new Error(`duplicate_card taskDueDates mismatch (${transportMode}): ${JSON.stringify(duplicateOutput)}`); } + if (duplicateOutput.card?.frontmatter?.start !== '2026-03-18') { + throw new Error(`duplicate_card start mismatch (${transportMode}): ${JSON.stringify(duplicateOutput)}`); + } + if (!Array.isArray(duplicateOutput.taskStartDates) || !duplicateOutput.taskStartDates.includes('2026-03-18') || !duplicateOutput.taskStartDates.includes('2026-03-19')) { + throw new Error(`duplicate_card taskStartDates mismatch (${transportMode}): ${JSON.stringify(duplicateOutput)}`); + } send({ jsonrpc: '2.0', @@ -789,7 +797,7 @@ async function runForTransport(transportMode, fixture) { path.join(fixture.boardRoot, trelloImportedList.name, trelloImportedCards[0]), 'utf8', ); - if (!trelloImportedCardBody.includes('title: Imported via MCP') || !trelloImportedCardBody.includes('due: 2026-03-30')) { + if (!trelloImportedCardBody.includes('title: Imported via MCP') || !trelloImportedCardBody.includes('start: 2026-03-28') || !trelloImportedCardBody.includes('due: 2026-03-30')) { throw new Error(`import_trello card content mismatch (${transportMode}): ${trelloImportedCardBody}`); } @@ -831,7 +839,7 @@ async function runForTransport(transportMode, fixture) { path.join(fixture.boardRoot, obsidianImportedList.name, obsidianImportedCards[0]), 'utf8', ); - if (!obsidianImportedCardBody.includes('title: MCP draft') || !obsidianImportedCardBody.includes('due: 2026-03-29')) { + if (!obsidianImportedCardBody.includes('title: MCP draft') || !obsidianImportedCardBody.includes('start: 2026-03-27') || !obsidianImportedCardBody.includes('due: 2026-03-29')) { throw new Error(`import_obsidian card content mismatch (${transportMode}): ${obsidianImportedCardBody}`); } @@ -989,9 +997,15 @@ async function runForTransport(transportMode, fixture) { const readCardOutput = readCardResponse.result?.structuredContent || {}; assertCardTimestamps(readCardOutput.card, `read_card (${transportMode})`); + if (readCardOutput.card?.frontmatter?.start !== '2026-03-18') { + throw new Error(`read_card start mismatch (${transportMode}): ${JSON.stringify(readCardOutput)}`); + } if (!readCardOutput.taskSummary || readCardOutput.taskSummary.total !== 3 || readCardOutput.taskSummary.completed !== 2) { throw new Error(`read_card taskSummary mismatch (${transportMode}): ${JSON.stringify(readCardOutput)}`); } + if (!Array.isArray(readCardOutput.taskStartDates) || !readCardOutput.taskStartDates.includes('2026-03-18') || !readCardOutput.taskStartDates.includes('2026-03-19')) { + throw new Error(`read_card taskStartDates mismatch (${transportMode}): ${JSON.stringify(readCardOutput)}`); + } if (!Array.isArray(readCardOutput.taskDueDates) || !readCardOutput.taskDueDates.includes('2026-03-20')) { throw new Error(`read_card taskDueDates mismatch (${transportMode}): ${JSON.stringify(readCardOutput)}`); } @@ -1006,7 +1020,8 @@ async function runForTransport(transportMode, fixture) { boardRoot: fixture.boardRoot, listName: fixture.leadsList, cardFile: fixture.templateCardFile, - body: 'Customer details go here.\\n- [x ] Initial outreach\\n- [x ] (due: 2026-03-20) Send proposal\\n- [ X] Confirm timeline\\n', + start: '2026-03-17', + body: 'Customer details go here.\\n- [x ] (start: 2026-03-18) Initial outreach\\n- [x ] (start: 2026-03-19) (due: 2026-03-20) Send proposal\\n- [ X] Confirm timeline\\n', }, }, }); @@ -1018,9 +1033,15 @@ async function runForTransport(transportMode, fixture) { const updateCardOutput = updateCardResponse.result?.structuredContent || {}; assertCardTimestamps(updateCardOutput.card, `update_card (${transportMode})`); + if (updateCardOutput.card?.frontmatter?.start !== '2026-03-17') { + throw new Error(`update_card start mismatch (${transportMode}): ${JSON.stringify(updateCardOutput)}`); + } if (!updateCardOutput.taskSummary || updateCardOutput.taskSummary.total !== 3 || updateCardOutput.taskSummary.completed !== 3) { throw new Error(`update_card taskSummary mismatch (${transportMode}): ${JSON.stringify(updateCardOutput)}`); } + if (!Array.isArray(updateCardOutput.taskStartDates) || !updateCardOutput.taskStartDates.includes('2026-03-18') || !updateCardOutput.taskStartDates.includes('2026-03-19')) { + throw new Error(`update_card taskStartDates mismatch (${transportMode}): ${JSON.stringify(updateCardOutput)}`); + } if (!Array.isArray(updateCardOutput.taskDueDates) || !updateCardOutput.taskDueDates.includes('2026-03-20')) { throw new Error(`update_card taskDueDates mismatch (${transportMode}): ${JSON.stringify(updateCardOutput)}`); } @@ -1035,7 +1056,8 @@ async function runForTransport(transportMode, fixture) { boardRoot: fixture.boardRoot, listName: fixture.leadsList, title: 'Task metadata coverage', - body: 'Created by MCP test.\\n- [x ] (due: 2026-03-21) Complete prep\\n- [ X] (due: 2026-03-22) Confirm review\\n- [ x] Share recap\\n- [ ] Follow up\\n', + start: '2026-03-23', + body: 'Created by MCP test.\\n- [x ] (start: 2026-03-21) (due: 2026-03-21) Complete prep\\n- [ X] (scheduled: 2026-03-22) (due: 2026-03-22) Confirm review\\n- [ x] Share recap\\n- [ ] Follow up\\n', }, }, }); @@ -1047,9 +1069,20 @@ async function runForTransport(transportMode, fixture) { const createCardOutput = createCardResponse.result?.structuredContent || {}; assertCardTimestamps(createCardOutput.card, `create_card (${transportMode})`); + if (createCardOutput.card?.frontmatter?.start !== '2026-03-23') { + throw new Error(`create_card start mismatch (${transportMode}): ${JSON.stringify(createCardOutput)}`); + } if (!createCardOutput.taskSummary || createCardOutput.taskSummary.total !== 4 || createCardOutput.taskSummary.completed !== 3) { throw new Error(`create_card taskSummary mismatch (${transportMode}): ${JSON.stringify(createCardOutput)}`); } + const expectedStartDates = ['2026-03-21', '2026-03-22']; + if ( + !Array.isArray(createCardOutput.taskStartDates) || + createCardOutput.taskStartDates.length !== expectedStartDates.length || + expectedStartDates.some((dateValue) => !createCardOutput.taskStartDates.includes(dateValue)) + ) { + throw new Error(`create_card taskStartDates mismatch (${transportMode}): ${JSON.stringify(createCardOutput)}`); + } const expectedDueDates = ['2026-03-21', '2026-03-22']; if ( !Array.isArray(createCardOutput.taskDueDates) || diff --git a/scripts/test-task-list-parser.js b/scripts/test-task-list-parser.js index 059875b..a187ceb 100644 --- a/scripts/test-task-list-parser.js +++ b/scripts/test-task-list-parser.js @@ -18,7 +18,16 @@ function loadTaskListUtilities() { function run() { const context = loadTaskListUtilities(); - const { getTaskListSummary, getTaskListDueDates, getIncompleteTaskListDueDates } = context; + const { + getTaskListSummary, + getTaskListDueDates, + getIncompleteTaskListDueDates, + getTaskListStartDates, + getIncompleteTaskListStartDates, + parseTaskListItems, + setTaskListItemStartDateByLineIndex, + setTaskListItemDueDateByLineIndex, + } = context; const toPlain = (value) => JSON.parse(JSON.stringify(value)); const baseline = [ @@ -63,6 +72,36 @@ function run() { 'Expected completed task due dates to be ignored when collecting incomplete task due dates', ); + const withStartAndDueDates = [ + '- [ ] (start: 2026-03-18) (due: 2026-03-20) Task one', + '- [x ] (scheduled: 2026-03-19) (due: 2026-03-21) Task two', + '- [ ] (due: 2026-03-22) (start: 2026-03-17) Task three', + ].join('\n'); + assert.deepStrictEqual( + toPlain(getTaskListStartDates(withStartAndDueDates)), + ['2026-03-17', '2026-03-18', '2026-03-19'], + 'Expected unique sorted task start dates', + ); + assert.deepStrictEqual( + toPlain(getIncompleteTaskListStartDates(withStartAndDueDates)), + ['2026-03-17', '2026-03-18'], + 'Expected completed task start dates to be ignored when collecting incomplete task start dates', + ); + const parsedStartDueTask = toPlain(parseTaskListItems(withStartAndDueDates)[0]); + assert.strictEqual(parsedStartDueTask.start, '2026-03-18'); + assert.strictEqual(parsedStartDueTask.due, '2026-03-20'); + assert.strictEqual(parsedStartDueTask.contentWithoutDateMarkers, 'Task one'); + assert.strictEqual( + setTaskListItemDueDateByLineIndex('- [ ] (start: 2026-03-18) Task one', 0, '2026-03-20'), + '- [ ] (start: 2026-03-18) (due: 2026-03-20) Task one', + 'Expected setting a due date to preserve an existing start marker', + ); + assert.strictEqual( + setTaskListItemStartDateByLineIndex('- [ ] (due: 2026-03-20) Task one', 0, '2026-03-18'), + '- [ ] (start: 2026-03-18) (due: 2026-03-20) Task one', + 'Expected setting a start date to preserve an existing due marker', + ); + const withOnlyCompletedDueTasks = [ '- [x ] (due: 2026-03-18) Closed task one', '- [X ] (due: 2026-03-19) Closed task two', diff --git a/static/styles.css b/static/styles.css index 21e1d5a..30b579d 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1241,7 +1241,7 @@ body:not(.board-empty) main#board.board-view-week { .board-table-view { width: 100%; - min-width: 1080px; + min-width: 1210px; } .board-table-header { @@ -1352,6 +1352,7 @@ body:not(.board-empty) main#board.board-view-week { width: 210px; } +.board-table-cell-start, .board-table-cell-due, .board-table-cell-updated, .board-table-cell-created, @@ -1408,6 +1409,7 @@ body:not(.board-empty) main#board.board-view-week { outline-offset: 1px; } +.board-table-start, .board-table-due { display: inline-flex; align-items: center; @@ -1419,10 +1421,12 @@ body:not(.board-empty) main#board.board-view-week { white-space: nowrap; } +.board-table-start.due-date-today, .board-table-due.due-date-today { color: var(--due-date-today-color); } +.board-table-start.due-date-tomorrow, .board-table-due.due-date-tomorrow { color: var(--due-date-tomorrow-color); } @@ -4287,6 +4291,70 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, width: 100%; } +.card-label-create-form { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; +} + +.card-label-create-input { + min-width: 0; + flex: 1 1 auto; + min-height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + font-size: var(--font-sm); + padding: 6px 8px; +} + +.card-label-create-input:focus-visible { + outline: 3px solid color-mix(in oklab, var(--accent) 45%, var(--bg-card)); + outline-offset: 1px; +} + +.card-label-create-input[aria-invalid="true"] { + border-color: var(--danger); +} + +.card-label-create-button { + min-height: 32px; + padding: 6px 10px; + white-space: nowrap; +} + +.new-card-label-button { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 6px; + max-width: 100%; + min-height: 34px; + margin-top: 8px; + font-size: var(--font-sm); + padding: 6px 10px; +} + +.new-card-label-button svg { + width: 15px; + height: 15px; + flex: 0 0 15px; +} + +.new-card-label-button-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.new-card-label-button.has-labels { + border-color: color-mix(in oklab, var(--accent) 40%, var(--border)); + color: color-mix(in oklab, var(--accent) 80%, var(--text)); +} + .list-actions-option { width: 100%; display: flex; @@ -4354,10 +4422,14 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, #modalEditCard .cardEditorMetadataButtons button:active { transform: none; } +#modalEditCard #cardEditorCardStartDateDisplay, #modalEditCard #cardEditorCardDueDateDisplay { font-size: var(--font-sm); color: var(--muted); } +#modalEditCard #cardEditorSetStartDateLink, #modalEditCard #cardEditorSetDueDateLink, #modalEditCard #cardEditorSetLabelsLink { text-decoration: none; } +#modalEditCard .cardEditorMetadataButtons #cardEditorSetStartDateLink.due-date-today, #modalEditCard .cardEditorMetadataButtons #cardEditorSetDueDateLink.due-date-today { color: var(--due-date-today-color); } +#modalEditCard .cardEditorMetadataButtons #cardEditorSetStartDateLink.due-date-tomorrow, #modalEditCard .cardEditorMetadataButtons #cardEditorSetDueDateLink.due-date-tomorrow { color: var(--due-date-tomorrow-color); } #modalEditCard .cardEditorHeader { @@ -4519,6 +4591,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, #modalEditCard .card-editor-linked-objects-count[hidden] { display: none; } +#modalEditCard #cardEditorCardStartDateDisplay, #modalEditCard #cardEditorCardDueDateDisplay, #modalEditCard #cardEditorCardLabels { margin-right: 0; } #modalEditCard #cardEditorCardLabels.card-editor-labels { @@ -4907,7 +4980,7 @@ select:disabled { #cardEditorOverType .overtype-input, #cardEditorOverType .overtype-preview { - padding-left: 29px !important; + padding-left: 49px !important; } #cardEditorOverType .task-line-due-layer { @@ -4917,6 +4990,7 @@ select:disabled { z-index: 6; } +#cardEditorOverType .task-line-start-control, #cardEditorOverType .task-line-due-control { position: absolute; display: inline-flex; @@ -4934,17 +5008,20 @@ select:disabled { cursor: pointer; } +#cardEditorOverType .task-line-start-control:hover, #cardEditorOverType .task-line-due-control:hover { border-color: color-mix(in oklab, var(--accent) 48%, var(--border)); color: color-mix(in oklab, var(--accent) 78%, var(--text)); } +#cardEditorOverType .task-line-start-control.has-start, #cardEditorOverType .task-line-due-control.has-due { border-color: color-mix(in oklab, var(--accent) 40%, var(--border)); background: color-mix(in oklab, var(--accent) 12%, var(--bg-card)); color: color-mix(in oklab, var(--accent) 80%, var(--text)); } +#cardEditorOverType .task-line-start-control svg, #cardEditorOverType .task-line-due-control svg { width: 14px; height: 14px; diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index b10b5a6..b06cd0d 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -1,4 +1,5 @@ const fs = require('fs').promises; +const net = require('net'); const os = require('os'); const path = require('path'); const electronBinary = require('electron'); @@ -50,6 +51,26 @@ function getCurrentWeekDate(dayOffset) { return new Date(today.getFullYear(), today.getMonth(), today.getDate() - mondayFirstOffset + dayOffset); } +async function getAvailableLoopbackPort() { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(port); + }); + }); + }); +} + async function openCurrentBoardPlannerView(page, shortcut, viewSelector) { await page.keyboard.press(getShortcut(shortcut)); await expect(page.locator('#plannerOverlay')).toBeVisible(); @@ -862,6 +883,18 @@ test('does not throw when formatting invalid due date values', async ({ page }) }); }); +test('closes the card date picker before opening the label picker', async ({ page }) => { + const firstCard = page.locator('.list').first().locator('.card').first(); + await firstCard.hover(); + + await firstCard.locator('.due-date-action').click(); + await expect(page.locator('.sb-themed-fdatepicker')).toBeVisible(); + + await firstCard.locator('.card-label-button').click(); + await expect(page.locator('.sb-themed-fdatepicker')).toBeHidden(); + await expect(page.locator('.card-label-popover')).toBeVisible(); +}); + test('navigates board search results from the keyboard', async ({ page }) => { const searchInput = page.locator('#boardSearchInput'); const planCardButton = page.locator('.card-title-button').filter({ hasText: 'Plan release notes' }); @@ -910,7 +943,7 @@ test('navigates board popovers and settings sections from the keyboard', async ( await page.keyboard.press('ArrowDown'); await expect(page.locator('#labelFilterPopover input').nth(1)).toBeFocused(); await page.keyboard.press('End'); - await expect(page.locator('#labelFilterPopover input').nth(3)).toBeFocused(); + await expect(page.locator('#labelFilterPopover input').last()).toBeFocused(); await page.keyboard.press('Escape'); await expect(page.locator('#labelFilterPopover')).toBeHidden(); await expect(filterButton).toBeFocused(); @@ -2422,6 +2455,7 @@ test('persists the global quick add shortcut setting', async ({ page }) => { }); test('publishes the External Published Calendar and respects board opt-out', async ({ page, boardRoot, request }) => { + const calendarPort = await getAvailableLoopbackPort(); await cardFrontmatter.updateFrontmatter( path.join(boardRoot, '000-To-do-stock', '000-plan-release-stock.md'), { due: '2026-04-05' }, @@ -2434,6 +2468,7 @@ test('publishes the External Published Calendar and respects board opt-out', asy const calendarToggle = page.locator('#boardSettingsExternalCalendarToggle'); const calendarStatus = page.locator('#boardSettingsExternalCalendarStatus'); const calendarPortGroup = page.locator('#boardSettingsExternalCalendarPortGroup'); + const calendarPortInput = page.locator('#boardSettingsExternalCalendarPort'); const calendarUrlGroup = page.locator('#boardSettingsExternalCalendarUrlGroup'); const calendarUrlInput = page.locator('#boardSettingsExternalCalendarUrl'); @@ -2450,22 +2485,28 @@ test('publishes the External Published Calendar and respects board opt-out', asy await expect(calendarUrlGroup).toBeVisible(); await expect(calendarUrlGroup).toHaveAttribute('aria-hidden', 'false'); + await calendarPortInput.fill(String(calendarPort)); + await calendarPortInput.press('Enter'); + await expect(calendarPortInput).toHaveValue(String(calendarPort)); + await expect.poll(async () => { return await page.evaluate(async () => { const settings = await window.electronAPI.readAppSettings(); return { enabled: settings.externalPublishedCalendar.enabled, + port: settings.externalPublishedCalendar.port, running: settings.externalPublishedCalendarStatus.running, url: settings.externalPublishedCalendarStatus.url, }; }); }).toMatchObject({ enabled: true, + port: calendarPort, running: true, }); await expect(calendarStatus).toContainText('Publishing'); - await expect(calendarUrlInput).toHaveValue(/http:\/\/127\.0\.0\.1:48273\/external-published-calendar\/.+\.ics/); + await expect(calendarUrlInput).toHaveValue(new RegExp(`http://127\\.0\\.0\\.1:${calendarPort}/external-published-calendar/.+\\.ics`)); const calendarUrl = await calendarUrlInput.inputValue(); const response = await request.get(calendarUrl); From 67e79fea6e91300c18cf27ef9362638c21b2af3f Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Tue, 16 Jun 2026 10:57:21 -0400 Subject: [PATCH 02/10] Added shortcut to get to label settings. Reliablility fixes --- AGENTS.md | 3 +- CODEX.md | 3 +- app/board/boardLabels.js | 100 +++- app/board/renderBoard.js | 10 + app/cards/createCardElement.js | 697 ++++++++++++++++++++--- app/init.js | 12 + app/lists/listActionsPopover.js | 5 + docs/README.md | 2 +- docs/codex/FILE_STRUCTURE.md | 6 +- docs/codex/PROJECT_CONTEXT.md | 6 +- docs/using-signboard.md | 10 +- readme.md | 2 +- scripts/test-board-card-metadata.js | 88 ++- static/styles.css | 153 ++++- tests/playwright/signboard-smoke.spec.js | 257 ++++++++- 15 files changed, 1220 insertions(+), 134 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b85de1e..cb54d9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,8 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - The sponsorship modal opens from the Board menu "Sponsor" action, About modal action, and fixed bottom-right "Sponsor" pill; the pill hides on compact windows to avoid covering board lists. - Quick Add card creation supports board/list selection across open boards, an optional app-level global shortcut while Signboard is running, and `Shift + Enter` to create, immediately open, and focus the notes field on the new card. - Card `start` frontmatter and task `(start: YYYY-MM-DD)` / `(scheduled: YYYY-MM-DD)` markers feed Kanban, Table, Planner, CLI, MCP, and importers alongside due dates; completed checklist date markers and completed-list cards stay non-actionable by default. -- Card label popovers can create board labels inline, and Add Card/Quick Add can preselect active-board labels before creating a card. +- Kanban cards show card-level `start` and `due` through one compact Dates control and body-level popover; the card editor keeps separate start/due controls. +- Card label popovers can create board labels inline, open the board's Labels settings from a gear shortcut, and Add Card/Quick Add can preselect active-board labels before creating a card. - External Published Calendar is opt-in in App Settings, served only on `127.0.0.1`, and board inclusion is controlled in each board's Workflow settings. - Completed-list workflow settings live in board settings; Planner, board date filters, and due notifications treat completed-list cards as non-actionable by default while preserving due dates. - Completed task-list item date markers should not keep cards in Planner/date-filter views; only incomplete task start/due markers should place cards there. diff --git a/CODEX.md b/CODEX.md index 4f5ef08..f4cf17c 100644 --- a/CODEX.md +++ b/CODEX.md @@ -36,8 +36,9 @@ Start here before opening source files. - Shared card drag options and tilt behavior live in `app/utilities/cardDragTilt.js`; the Sortable ghost is styled in `static/styles.css` as an empty drop slot rather than a translucent duplicate card. - Card and list rendering should preserve native keyboard controls and screen-reader structure: cards expose a button title inside each list item, lists expose labelled sections, and editable list/card titles use subtle keyboard-only focus affordances rather than persistent editor outlines. - Board, Planner, and Archive search support keyboard result navigation: `Enter` / arrows from the search field move into visible results, arrows move between results, `Esc` returns to search, and `Esc` from search clears the query where applicable. Board tabs, list actions, label/filter popovers, and Settings sections also support arrow-key movement plus `Home` / `End`; popovers restore focus to their opener on `Esc`, and focused board tabs close with `Delete` / `Backspace`. -- The header filter popover is owned by `app/board/boardLabels.js`; it supports temporary `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days` date filters plus multi-select label filters for Kanban and Table. Card-label popovers can create board labels inline, and Add Card/Quick Add can assign existing or newly created active-board labels before the card file is written. +- The header filter popover is owned by `app/board/boardLabels.js`; it supports temporary `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days` date filters plus multi-select label filters for Kanban and Table. Card-label popovers can create board labels inline, open the board's Labels settings from a gear shortcut, and Add Card/Quick Add can assign existing or newly created active-board labels before the card file is written. - Date filters and Planner date views intentionally ignore completed task-level date markers and completed-list cards by default; card-level `start`/`due` dates still count for actionable lists, and incomplete task `start`/`due` markers still drive matches in Kanban, Table, and Planner. +- Kanban cards expose card-level `start` and `due` through one compact Dates metadata control and body-level popover; the card editor keeps separate start/due controls. - The filter toolbar button is icon-only; when filters are active it gets an accent-tinted active state and exposes the active summary through tooltip/ARIA text rather than visible label text. - Keyboard shortcut handling is centralized in `app/listeners/window.js`; the helper modal is rendered in `index.html` as `#modalKeyboardShortcuts`, renderer shortcuts cover Quick Add card creation (`Cmd/Ctrl + N`, with board/list selection across open boards), quick board switching (`Cmd/Ctrl + K`), board view switching (`Cmd/Ctrl + 1` closes Planner back to Kanban, `Cmd/Ctrl + Option/Alt + 1` closes Planner back to Table), Planner toggle/view switching (`Cmd/Ctrl + Shift + P`, `Cmd/Ctrl + 2/3/4/5` opens the matching Planner date view for all open boards, `Cmd/Ctrl + Option/Alt + 2/3/4/5` opens the matching Planner date view scoped to the current board), Settings (`Cmd/Ctrl + ,`), color-scheme cycling, archive browsing, and active-card move/archive actions. Workspace-level shortcuts close the active card editor before changing context, while editor-scoped move/archive shortcuts keep acting on the open card. Native menu accelerators cover board switching, board view switching, Settings, and theme toggling. The optional app-level Quick Add global shortcut is registered in `main.js` with Electron `globalShortcut` and sends the same renderer Quick Add command while Signboard is running. - Shortcut label formatting is shared from `app/board/boardLabels.js`, so the keyboard helper modal, board/view menus, and list-action popovers all stay OS-aware and in sync. diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index cdc9831..043d21e 100644 --- a/app/board/boardLabels.js +++ b/app/board/boardLabels.js @@ -1829,6 +1829,11 @@ function closeCardLabelPopover() { state.activeCardLabelPopover = null; } +function isCardLabelPopoverOpen() { + const state = getBoardLabelState(); + return Boolean(state.activeCardLabelPopover && state.activeCardLabelPopover.parentNode); +} + function closeCardLabelSelectorIfClickOutside(target) { const state = getBoardLabelState(); const popover = state.activeCardLabelPopover; @@ -1844,6 +1849,36 @@ function closeCardLabelSelectorIfClickOutside(target) { closeCardLabelPopover(); } +async function openBoardLabelSettingsFromPopover() { + if (!window.boardRoot) { + return; + } + + closeBoardLabelFilterPopover(); + closeCardLabelPopover(); + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } + if (typeof closeBoardMenuPopover === 'function') { + closeBoardMenuPopover(); + } + if (typeof closeBoardViewPopover === 'function') { + closeBoardViewPopover(); + } + if (typeof closeListActionsPopover === 'function') { + closeListActionsPopover(); + } + if (typeof closeAllModals === 'function') { + await closeAllModals({ key: 'Escape' }); + } + + await ensureBoardLabelsLoaded(); + openBoardSettingsModal({ + panel: 'labels', + initialFocus: '#boardSettingsLabels .board-settings-label-name, #btnAddBoardLabel', + }); +} + function positionCardLabelPopover(popover, anchorElement) { const bounds = anchorElement.getBoundingClientRect(); popover.style.position = 'fixed'; @@ -1914,6 +1949,46 @@ function createCardLabelCreateForm(knownSelection, unknownLabelIds, onChange) { return form; } +function createCardLabelPopoverHeader() { + const header = document.createElement('div'); + header.className = 'card-label-popover-header'; + + const title = document.createElement('span'); + title.className = 'card-label-popover-title'; + title.textContent = 'Labels'; + + const settingsButton = document.createElement('button'); + settingsButton.type = 'button'; + settingsButton.className = 'card-label-settings-shortcut'; + settingsButton.title = 'Open label settings'; + settingsButton.setAttribute('aria-label', 'Open label settings'); + settingsButton.innerHTML = '<i data-feather="settings"></i>'; + settingsButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await openBoardLabelSettingsFromPopover(); + }); + + header.append(title, settingsButton); + return header; +} + +function focusFirstCardLabelPopoverControl(popover) { + if (!popover || typeof popover.querySelector !== 'function') { + return false; + } + + const preferredControl = popover.querySelector( + '.label-popover-row input:not(:disabled), .card-label-create-input:not(:disabled)', + ); + if (preferredControl && typeof preferredControl.focus === 'function') { + preferredControl.focus(); + return true; + } + + return focusFirstLabelPopoverControl(popover); +} + function createCardLabelPopoverContent(selectedLabelIds, onChange) { const labels = getBoardLabels(); const knownLabelIds = new Set(labels.map((label) => label.id)); @@ -1921,6 +1996,7 @@ function createCardLabelPopoverContent(selectedLabelIds, onChange) { const knownSelection = new Set(selectedLabelIds.filter((labelId) => knownLabelIds.has(labelId))); const fragment = document.createDocumentFragment(); + fragment.appendChild(createCardLabelPopoverHeader()); if (labels.length === 0) { const emptyState = document.createElement('p'); @@ -1987,6 +2063,9 @@ function toggleCardLabelSelector(anchorElement, cardPath, selectedLabelIds, onCh if (typeof destroyActiveDueDatePicker === 'function') { destroyActiveDueDatePicker(); } + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } if (popover && popover.__anchorElement === anchorElement) { closeCardLabelPopover(); @@ -2028,8 +2107,11 @@ function toggleCardLabelSelector(anchorElement, cardPath, selectedLabelIds, onCh }); document.body.appendChild(menu); + if (typeof feather !== 'undefined' && feather && typeof feather.replace === 'function') { + feather.replace(); + } positionCardLabelPopover(menu, anchorElement); - focusFirstLabelPopoverControl(menu); + focusFirstCardLabelPopoverControl(menu); state.activeCardLabelPopover = menu; } @@ -3143,12 +3225,19 @@ async function moveCurrentBoardDirectory(nextParentDirectory) { return moveBoardDirectory(nextBoardRoot); } -function openBoardSettingsModal() { +function openBoardSettingsModal(options = {}) { const modal = document.getElementById('modalBoardSettings'); if (!modal) { return; } + const panel = typeof options === 'string' + ? options + : String(options.panel || 'app'); + const initialFocus = options && typeof options === 'object' && options.initialFocus + ? options.initialFocus + : (panel === 'labels' ? '#boardSettingsNavLabels' : '#boardSettingsNavApp'); + closeBoardLabelFilterPopover(); closeCardLabelPopover(); renderBoardSettingsLabels(); @@ -3158,11 +3247,11 @@ function openBoardSettingsModal() { renderBoardExternalPublishedCalendarSettingsControls(); renderAppSettingsControls(); renderBoardImportControls(); - setActiveBoardSettingsPanel('app'); + setActiveBoardSettingsPanel(panel); if (typeof setAccessibleModalVisible === 'function') { setAccessibleModalVisible(modal, true, { display: 'block', - initialFocus: '#boardSettingsNavApp', + initialFocus, labelledBy: 'boardSettingsTitle', }); } else { @@ -3310,6 +3399,9 @@ function initializeBoardLabelControls() { return; } closeCardLabelPopover(); + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } if (typeof closeBoardViewPopover === 'function') { closeBoardViewPopover(); } diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index 45657a9..21c3022 100644 --- a/app/board/renderBoard.js +++ b/app/board/renderBoard.js @@ -28,6 +28,13 @@ function getBoardRenderState() { function destroyBoardSortables() { const state = getBoardRenderState(); + if (typeof endBoardCardDragTilt === 'function') { + endBoardCardDragTilt(); + } + if (typeof unlockBoardCardTextSelection === 'function') { + unlockBoardCardTextSelection(); + } + for (const sortable of state.activeSortables) { if (sortable && typeof sortable.destroy === 'function') { sortable.destroy(); @@ -317,6 +324,9 @@ async function renderBoard() { const boardRoot = window.boardRoot; // set in the drop-zone handler closeCardLabelPopover(); + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } if (typeof closeListActionsPopover === 'function') { closeListActionsPopover(); } diff --git a/app/cards/createCardElement.js b/app/cards/createCardElement.js index 98d1e5b..17b549f 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -1,3 +1,543 @@ +const BOARD_CARD_ACTIVATION_INTERACTIVE_SELECTOR = [ + 'button:not(.card-title-button)', + 'a', + 'input', + 'select', + 'textarea', + '[contenteditable="true"]', + '[contenteditable="plaintext-only"]', + '.card-label-chip', +].join(', '); + +const BOARD_CARD_POINTER_ACTIVATION_MAX_DISTANCE_PX = 6; +const BOARD_CARD_POINTER_ACTIVATION_MAX_AGE_MS = 1000; + +let boardCardPointerActivationState = null; +let boardCardPointerActivationFallbackInitialized = false; +let activeCardDatePopover = null; + +function parseBoardCardDateValue(dateValue) { + if (typeof parseIsoDateStringToLocalDate === 'function') { + return parseIsoDateStringToLocalDate(dateValue); + } + + const normalized = String(dateValue || '').trim(); + const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + const day = Number(match[3]); + const parsedDate = new Date(year, monthIndex, day); + + if ( + Number.isNaN(parsedDate.getTime()) || + parsedDate.getFullYear() !== year || + parsedDate.getMonth() !== monthIndex || + parsedDate.getDate() !== day + ) { + return null; + } + + return parsedDate; +} + +function getCardDateRangeDisplayLabel(startDateValue, dueDateValue, startLabel, dueLabel) { + if (startDateValue === dueDateValue || startLabel === dueLabel) { + return dueLabel; + } + + const startDate = parseBoardCardDateValue(startDateValue); + const dueDate = parseBoardCardDateValue(dueDateValue); + + if ( + startDate && + dueDate && + startDate.getFullYear() === dueDate.getFullYear() && + startDate.getMonth() === dueDate.getMonth() + ) { + return `${startLabel}-${dueDate.getDate()}`; + } + + return `${startLabel} - ${dueLabel}`; +} + +function isBoardCardActivationElement(target) { + return typeof Element !== 'undefined' && target instanceof Element; +} + +function isBoardCardInteractiveActivationTarget(target) { + if (!isBoardCardActivationElement(target) || typeof target.closest !== 'function') { + return false; + } + + return Boolean(target.closest(BOARD_CARD_ACTIVATION_INTERACTIVE_SELECTOR)); +} + +function isBoardCardEditorVisible() { + const modalEditCard = document.getElementById('modalEditCard'); + return Boolean( + modalEditCard && + !modalEditCard.classList.contains('hidden') && + modalEditCard.getAttribute('aria-hidden') !== 'true' && + modalEditCard.style.display !== 'none' + ); +} + +function isBoardCardPointerActivationBlocked() { + if (typeof document === 'undefined' || !document.body) { + return true; + } + + return Boolean( + document.body.classList.contains('board-card-drag-active') || + document.querySelector('.card-sortable--dragging, .card-sortable--fallback') + ); +} + +function getNormalizedBoardCardActivationPath(cardPath) { + const normalized = typeof normalizeBoardPath === 'function' + ? normalizeBoardPath(cardPath) + : String(cardPath || '').replace(/\\/g, '/').trim(); + + return normalized; +} + +function isBoardCardActivationPathOnCurrentBoard(cardPath) { + const normalizedCardPath = getNormalizedBoardCardActivationPath(cardPath); + const normalizedBoardRoot = getNormalizedBoardCardActivationPath(window.boardRoot || ''); + + if (!normalizedCardPath || !normalizedBoardRoot) { + return false; + } + + const normalizedBoardRootWithSlash = normalizedBoardRoot.endsWith('/') + ? normalizedBoardRoot + : `${normalizedBoardRoot}/`; + + return normalizedCardPath.startsWith(normalizedBoardRootWithSlash); +} + +function beginBoardCardPointerActivationFallback(event) { + boardCardPointerActivationState = null; + + if (!event || event.isPrimary === false || (typeof event.button === 'number' && event.button !== 0)) { + return; + } + + const target = isBoardCardActivationElement(event.target) ? event.target : null; + if (!target || isBoardCardInteractiveActivationTarget(target)) { + return; + } + + const cardEl = target.closest('.card[data-path]'); + if (!cardEl || isBoardCardPointerActivationBlocked()) { + return; + } + + const cardPath = String(cardEl.dataset.path || '').trim(); + if (!cardPath) { + return; + } + + boardCardPointerActivationState = { + cardPath, + pointerId: typeof event.pointerId === 'number' ? event.pointerId : null, + clientX: typeof event.clientX === 'number' ? event.clientX : 0, + clientY: typeof event.clientY === 'number' ? event.clientY : 0, + startedAt: Date.now(), + }; +} + +function cancelBoardCardPointerActivationFallback() { + boardCardPointerActivationState = null; +} + +function completeBoardCardPointerActivationFallback(event) { + const activationState = boardCardPointerActivationState; + boardCardPointerActivationState = null; + + if (!activationState || !event || event.isPrimary === false) { + return; + } + + if ( + activationState.pointerId !== null && + typeof event.pointerId === 'number' && + event.pointerId !== activationState.pointerId + ) { + return; + } + + const clientX = typeof event.clientX === 'number' ? event.clientX : activationState.clientX; + const clientY = typeof event.clientY === 'number' ? event.clientY : activationState.clientY; + const movedDistance = Math.hypot(clientX - activationState.clientX, clientY - activationState.clientY); + if (movedDistance > BOARD_CARD_POINTER_ACTIVATION_MAX_DISTANCE_PX) { + return; + } + + const activationAge = Date.now() - activationState.startedAt; + if (activationAge > BOARD_CARD_POINTER_ACTIVATION_MAX_AGE_MS) { + return; + } + + if (isBoardCardInteractiveActivationTarget(event.target) || isBoardCardPointerActivationBlocked()) { + return; + } + + window.setTimeout(async () => { + if ( + isBoardCardEditorVisible() || + isBoardCardPointerActivationBlocked() || + !isBoardCardActivationPathOnCurrentBoard(activationState.cardPath) || + typeof toggleEditCardModal !== 'function' + ) { + return; + } + + try { + await toggleEditCardModal(activationState.cardPath); + } catch (error) { + console.error('Failed to open card after delayed pointer activation.', error); + } + }, 0); +} + +function initializeBoardCardPointerActivationFallback() { + if (boardCardPointerActivationFallbackInitialized || typeof document === 'undefined') { + return; + } + + boardCardPointerActivationFallbackInitialized = true; + document.addEventListener('pointerdown', beginBoardCardPointerActivationFallback, true); + document.addEventListener('pointerup', completeBoardCardPointerActivationFallback, true); + document.addEventListener('pointercancel', cancelBoardCardPointerActivationFallback, true); +} + +function closeCardDatePopover(options = {}) { + const shouldKeepDatePicker = Boolean(options && options.keepDatePicker); + if (!shouldKeepDatePicker && typeof destroyActiveDueDatePicker === 'function') { + destroyActiveDueDatePicker(); + } + + if (activeCardDatePopover && activeCardDatePopover.parentNode) { + activeCardDatePopover.parentNode.removeChild(activeCardDatePopover); + } + + activeCardDatePopover = null; +} + +function isCardDatePopoverOpen() { + return Boolean(activeCardDatePopover && activeCardDatePopover.parentNode); +} + +function closeCardDatePopoverIfClickOutside(target) { + const popover = activeCardDatePopover; + if (!popover || !isBoardCardActivationElement(target)) { + return; + } + + const anchorElement = popover.__anchorElement; + if ( + (anchorElement && anchorElement.contains(target)) || + popover.contains(target) || + target.closest('.sb-themed-fdatepicker, [data-fdatepicker="due-date-anchor"]') + ) { + return; + } + + closeCardDatePopover(); +} + +function positionCardDatePopover(popover, anchorElement) { + if ( + !popover || + !anchorElement || + typeof anchorElement.getBoundingClientRect !== 'function' || + typeof popover.getBoundingClientRect !== 'function' + ) { + return; + } + + const viewportPadding = 8; + const anchorRect = anchorElement.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + const nextWidth = popoverRect.width || 260; + let nextLeft = anchorRect.left; + let nextTop = anchorRect.bottom + 6; + + if (nextLeft + nextWidth > window.innerWidth - viewportPadding) { + nextLeft = window.innerWidth - nextWidth - viewportPadding; + } + if (nextTop + popoverRect.height > window.innerHeight - viewportPadding) { + nextTop = anchorRect.top - popoverRect.height - 6; + } + + popover.style.left = `${Math.round(Math.max(viewportPadding, nextLeft))}px`; + popover.style.top = `${Math.round(Math.max(viewportPadding, nextTop))}px`; +} + +function focusFirstCardDatePopoverControl(popover) { + if (!popover || typeof popover.querySelector !== 'function') { + return; + } + + const firstControl = popover.querySelector('button:not([disabled])'); + if (firstControl && typeof firstControl.focus === 'function') { + firstControl.focus(); + } +} + +function handleCardDatePopoverKeyboard(event, popover) { + if (!event || !popover) { + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + const anchorElement = popover.__anchorElement; + closeCardDatePopover(); + if (anchorElement && typeof anchorElement.focus === 'function') { + anchorElement.focus(); + } + return; + } + + const verticalDirection = event.key === 'ArrowDown' + ? 1 + : (event.key === 'ArrowUp' ? -1 : 0); + if (!verticalDirection && event.key !== 'Home' && event.key !== 'End') { + return; + } + + const controls = Array.from(popover.querySelectorAll('button:not([disabled])')); + if (controls.length === 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const activeIndex = controls.indexOf(document.activeElement); + let nextIndex = activeIndex >= 0 ? activeIndex : 0; + if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = controls.length - 1; + } else { + nextIndex = (nextIndex + verticalDirection + controls.length) % controls.length; + } + + controls[nextIndex].focus(); +} + +async function getCardDatePopoverDisplayValue(options, dateValue) { + const normalizedDateValue = String(dateValue || '').trim(); + if (!normalizedDateValue) { + return 'Not set'; + } + + if (options && typeof options.formatDateValue === 'function') { + return await options.formatDateValue(normalizedDateValue); + } + + return normalizedDateValue; +} + +function createCardDatePopoverFieldRow(popover, fieldName, labelText, dateValue, displayValue) { + const row = document.createElement('div'); + row.className = 'card-date-popover-row'; + row.dataset.field = fieldName; + row.classList.toggle('has-clear', Boolean(dateValue)); + + const fieldButton = document.createElement('button'); + fieldButton.type = 'button'; + fieldButton.className = 'card-date-popover-field'; + fieldButton.setAttribute('aria-label', `${dateValue ? 'Change' : 'Set'} ${labelText.toLowerCase()}`); + + const label = document.createElement('span'); + label.className = 'card-date-popover-field-label'; + label.textContent = labelText; + + const value = document.createElement('span'); + value.className = 'card-date-popover-field-value'; + value.textContent = displayValue; + if (!dateValue) { + value.classList.add('is-empty'); + } + + fieldButton.append(label, value); + fieldButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + + const options = popover.__dateOptions || {}; + const getValue = fieldName === 'start' + ? options.getStartDateValue + : options.getDueDateValue; + const onSelect = fieldName === 'start' + ? options.onSelectStart + : options.onSelectDue; + const currentValue = typeof getValue === 'function' + ? String(getValue() || '').trim() + : ''; + + openDueDatePickerAtTrigger({ + triggerElement: fieldButton, + dueDateValue: currentValue, + onSelect: async (nextDateValue) => { + if (typeof onSelect === 'function') { + await onSelect(nextDateValue); + } + + if (activeCardDatePopover === popover && popover.parentNode) { + await renderCardDatePopover(popover); + positionCardDatePopover(popover, popover.__anchorElement); + } + }, + }); + }); + + row.appendChild(fieldButton); + + if (dateValue) { + const clearButton = document.createElement('button'); + clearButton.type = 'button'; + clearButton.className = 'card-date-popover-clear'; + clearButton.title = `Clear ${labelText.toLowerCase()}`; + clearButton.setAttribute('aria-label', `Clear ${labelText.toLowerCase()}`); + clearButton.innerHTML = '<i data-feather="x"></i>'; + clearButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (typeof destroyActiveDueDatePicker === 'function') { + destroyActiveDueDatePicker(); + } + + const options = popover.__dateOptions || {}; + const onSelect = fieldName === 'start' + ? options.onSelectStart + : options.onSelectDue; + if (typeof onSelect === 'function') { + await onSelect(''); + } + + if (activeCardDatePopover === popover && popover.parentNode) { + await renderCardDatePopover(popover); + positionCardDatePopover(popover, popover.__anchorElement); + focusFirstCardDatePopoverControl(popover); + } + }); + row.appendChild(clearButton); + } + + return row; +} + +async function renderCardDatePopover(popover) { + if (!popover || !popover.__dateOptions) { + return; + } + + const options = popover.__dateOptions; + const startDateValue = typeof options.getStartDateValue === 'function' + ? String(options.getStartDateValue() || '').trim() + : ''; + const dueDateValue = typeof options.getDueDateValue === 'function' + ? String(options.getDueDateValue() || '').trim() + : ''; + const startDisplayValue = await getCardDatePopoverDisplayValue(options, startDateValue); + const dueDisplayValue = await getCardDatePopoverDisplayValue(options, dueDateValue); + + const content = document.createElement('div'); + content.className = 'card-date-popover-content'; + + const header = document.createElement('div'); + header.className = 'card-date-popover-header'; + + const title = document.createElement('span'); + title.className = 'card-date-popover-title'; + title.textContent = 'Dates'; + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'card-date-popover-close'; + closeButton.title = 'Close dates'; + closeButton.setAttribute('aria-label', 'Close dates'); + closeButton.innerHTML = '<i data-feather="x"></i>'; + closeButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + closeCardDatePopover(); + if (popover.__anchorElement && typeof popover.__anchorElement.focus === 'function') { + popover.__anchorElement.focus(); + } + }); + + header.append(title, closeButton); + content.appendChild(header); + content.appendChild(createCardDatePopoverFieldRow(popover, 'start', 'Start date', startDateValue, startDisplayValue)); + content.appendChild(createCardDatePopoverFieldRow(popover, 'due', 'Due date', dueDateValue, dueDisplayValue)); + + popover.replaceChildren(content); + if (typeof feather !== 'undefined' && feather && typeof feather.replace === 'function') { + feather.replace(); + } +} + +async function toggleCardDateSelector(options = {}) { + const anchorElement = options.anchorElement; + if (!anchorElement || typeof document === 'undefined' || !document.body) { + return; + } + + if (activeCardDatePopover && activeCardDatePopover.__anchorElement === anchorElement) { + closeCardDatePopover(); + return; + } + + closeCardDatePopover(); + if (typeof closeBoardLabelFilterPopover === 'function') { + closeBoardLabelFilterPopover(); + } + if (typeof closeCardLabelPopover === 'function') { + closeCardLabelPopover(); + } + if (typeof closeListActionsPopover === 'function') { + closeListActionsPopover(); + } + if (typeof closeBoardViewPopover === 'function') { + closeBoardViewPopover(); + } + if (typeof closeBoardMenuPopover === 'function') { + closeBoardMenuPopover(); + } + + const popover = document.createElement('div'); + popover.className = 'card-date-popover'; + popover.setAttribute('role', 'group'); + popover.setAttribute('aria-label', 'Card dates'); + popover.setAttribute('aria-hidden', 'false'); + popover.__anchorElement = anchorElement; + popover.__dateOptions = options; + popover.addEventListener('click', (event) => { + event.stopPropagation(); + }); + popover.addEventListener('keydown', (event) => { + handleCardDatePopoverKeyboard(event, popover); + }); + + document.body.appendChild(popover); + activeCardDatePopover = popover; + await renderCardDatePopover(popover); + positionCardDatePopover(popover, anchorElement); + focusFirstCardDatePopoverControl(popover); +} + async function createCardElement(cardPath) { const card = await window.board.readCard(cardPath); const listDirectoryName = String(cardPath || '').replace(/\\/g, '/').split('/').slice(-2, -1)[0] || ''; @@ -61,31 +601,18 @@ async function createCardElement(cardPath) { const metadata = document.createElement('div'); metadata.className = 'metadata'; - const startButton = document.createElement('button'); - startButton.type = 'button'; - startButton.className = 'metadata-action start-date-action'; - - const startIcon = document.createElement('i'); - startIcon.setAttribute('data-feather', 'play-circle'); - startButton.appendChild(startIcon); - - const formattedStart = document.createElement('span'); - formattedStart.className = 'formatted-date'; - startButton.appendChild(formattedStart); - metadata.appendChild(startButton); - - const dueButton = document.createElement('button'); - dueButton.type = 'button'; - dueButton.className = 'metadata-action due-date-action'; + const dateButton = document.createElement('button'); + dateButton.type = 'button'; + dateButton.className = 'metadata-action card-date-action'; - const dueIcon = document.createElement('i'); - dueIcon.setAttribute('data-feather', 'clock'); - dueButton.appendChild(dueIcon); + const dateIcon = document.createElement('i'); + dateIcon.setAttribute('data-feather', 'clock'); + dateButton.appendChild(dateIcon); - const formattedDue = document.createElement('span'); - formattedDue.className = 'formatted-date'; - dueButton.appendChild(formattedDue); - metadata.appendChild(dueButton); + const formattedDates = document.createElement('span'); + formattedDates.className = 'card-date-label'; + dateButton.appendChild(formattedDates); + metadata.appendChild(dateButton); const taskProgressBadge = createTaskProgressBadge( taskSummary, @@ -130,57 +657,73 @@ async function createCardElement(cardPath) { ); } - async function renderStartDateDisplay() { - if (!startDateValue) { - formattedStart.textContent = ''; - setDueDateVisualClass(startButton, ''); - return; - } - - formattedStart.textContent = await window.board.formatDueDate(startDateValue); - setDueDateVisualClass(startButton, startDateValue); - } - - async function renderDueDateDisplay() { - if (!dueDateValue) { - formattedDue.textContent = ''; - setDueDateVisualClass(dueButton, ''); - return; - } - - formattedDue.textContent = await window.board.formatDueDate(dueDateValue); - setDueDateVisualClass(dueButton, dueDateValue); - } - function setMetadataActionVisibility() { const hasStartDate = startDateValue.length > 0; const hasDueDate = dueDateValue.length > 0; + const hasDates = hasStartDate || hasDueDate; const hasLabels = selectedLabelIds.length > 0; const hasTasks = taskSummary.total > 0; const hasLinkedObjects = linkedObjectCount > 0; - const hasAnyMetadata = hasStartDate || hasDueDate || hasLabels || hasTasks || hasLinkedObjects; + const hasAnyMetadata = hasDates || hasLabels || hasTasks || hasLinkedObjects; metadata.classList.toggle('metadata-discovery', !hasAnyMetadata); - startButton.classList.toggle('metadata-action-empty', !hasStartDate); - dueButton.classList.toggle('metadata-action-empty', !hasDueDate); + dateButton.classList.toggle('metadata-action-empty', !hasDates); labelButton.classList.toggle('metadata-action-empty', !hasLabels); + } - if (hasStartDate) { - startButton.title = 'Change start date'; - startButton.setAttribute('aria-label', 'Change start date'); + async function renderCardDateDisplay() { + const hasStartDate = startDateValue.length > 0; + const hasDueDate = dueDateValue.length > 0; + const [startLabel, dueLabel] = await Promise.all([ + hasStartDate ? window.board.formatDueDate(startDateValue) : '', + hasDueDate ? window.board.formatDueDate(dueDateValue) : '', + ]); + + if (hasStartDate && hasDueDate) { + formattedDates.textContent = getCardDateRangeDisplayLabel( + startDateValue, + dueDateValue, + startLabel, + dueLabel, + ); + dateButton.title = `Dates: ${formattedDates.textContent}`; + dateButton.setAttribute('aria-label', `Dates ${startLabel} through ${dueLabel}. Change dates.`); + } else if (hasStartDate) { + formattedDates.textContent = `Starts ${startLabel}`; + dateButton.title = `Starts ${startLabel}`; + dateButton.setAttribute('aria-label', `Starts ${startLabel}. Change dates.`); + } else if (hasDueDate) { + formattedDates.textContent = `Due ${dueLabel}`; + dateButton.title = `Due ${dueLabel}`; + dateButton.setAttribute('aria-label', `Due ${dueLabel}. Change dates.`); } else { - startButton.title = 'Set start date'; - startButton.setAttribute('aria-label', 'Set start date'); + formattedDates.textContent = ''; + dateButton.title = 'Set dates'; + dateButton.setAttribute('aria-label', 'Set dates'); } - if (hasDueDate) { - dueButton.title = 'Change due date'; - dueButton.setAttribute('aria-label', 'Change due date'); - } else { - dueButton.title = 'Set due date'; - dueButton.setAttribute('aria-label', 'Set due date'); - } + setDueDateVisualClass(dateButton, dueDateValue || startDateValue || ''); + setMetadataActionVisibility(); + } + + async function openCardDatesChooser() { + await toggleCardDateSelector({ + anchorElement: dateButton, + cardPath, + getStartDateValue: () => startDateValue, + getDueDateValue: () => dueDateValue, + formatDateValue: (value) => window.board.formatDueDate(value), + onSelectStart: async (value) => { + await updateCardStartDate(value); + }, + onSelectDue: async (value) => { + await updateCardDueDate(value); + }, + }); + } + function updateLabelMetadataVisibility() { + const hasLabels = selectedLabelIds.length > 0; if (hasLabels) { labelButton.title = 'Edit labels'; labelButton.setAttribute('aria-label', 'Edit labels'); @@ -221,6 +764,7 @@ async function createCardElement(cardPath) { } setMetadataActionVisibility(); + updateLabelMetadataVisibility(); } async function updateCardStartDate(nextStartDateValue) { @@ -229,8 +773,7 @@ async function createCardElement(cardPath) { card.frontmatter.start = nextStartDate; await window.board.updateFrontmatter(cardPath, { start: nextStartDate }); - await renderStartDateDisplay(); - setMetadataActionVisibility(); + await renderCardDateDisplay(); const cardDates = getAllCardFilterDates(); const activeFilterDates = getActiveCardFilterDates(); @@ -245,8 +788,7 @@ async function createCardElement(cardPath) { card.frontmatter.due = nextDueDate; await window.board.updateFrontmatter(cardPath, { due: nextDueDate }); - await renderDueDateDisplay(); - setMetadataActionVisibility(); + await renderCardDateDisplay(); const cardDates = getAllCardFilterDates(); const activeFilterDates = getActiveCardFilterDates(); @@ -282,32 +824,13 @@ async function createCardElement(cardPath) { ); } - await renderStartDateDisplay(); - await renderDueDateDisplay(); + await renderCardDateDisplay(); renderCardLabels(); - startButton.addEventListener('click', async (event) => { + dateButton.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); - openDueDatePickerAtTrigger({ - triggerElement: startButton, - dueDateValue: startDateValue, - onSelect: async (value) => { - await updateCardStartDate(value); - }, - }); - }); - - dueButton.addEventListener('click', async (event) => { - event.preventDefault(); - event.stopPropagation(); - openDueDatePickerAtTrigger({ - triggerElement: dueButton, - dueDateValue, - onSelect: async (value) => { - await updateCardDueDate(value); - }, - }); + await openCardDatesChooser(); }); labelButton.addEventListener('click', (event) => { @@ -365,7 +888,7 @@ async function createCardElement(cardPath) { cardEl.addEventListener('click', async (event) => { const target = event.target instanceof Element ? event.target : null; - if (target && typeof target.closest === 'function' && target.closest('button, a, input, select, textarea, [contenteditable="true"], [contenteditable="plaintext-only"]')) { + if (isBoardCardInteractiveActivationTarget(target)) { return; } diff --git a/app/init.js b/app/init.js index 8e862fb..03c217d 100644 --- a/app/init.js +++ b/app/init.js @@ -189,6 +189,9 @@ function isBoardDragInProgress() { function isExternalBoardRefreshBlocked() { return isBoardDragInProgress() + || (typeof isCardDatePopoverOpen === 'function' && isCardDatePopoverOpen()) + || (typeof isCardLabelPopoverOpen === 'function' && isCardLabelPopoverOpen()) + || (typeof isListActionsPopoverOpen === 'function' && isListActionsPopoverOpen()) || isModalOpen('modalEditCard') || isModalOpen('modalBoardSettings') || isModalOpen('modalArchiveBrowser') @@ -624,6 +627,9 @@ function toggleBoardMenuPopover() { if (typeof closeCardLabelPopover === 'function') { closeCardLabelPopover(); } + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } if (typeof closeListActionsPopover === 'function') { closeListActionsPopover(); } @@ -924,6 +930,9 @@ async function init() { initializeAccessibilityHelpers(); } initializeTooltips(); + if (typeof initializeBoardCardPointerActivationFallback === 'function') { + initializeBoardCardPointerActivationFallback(); + } if (window.electronAPI && typeof window.electronAPI.onOpenSignboardCardLink === 'function') { window.electronAPI.onOpenSignboardCardLink((payload) => { @@ -1073,6 +1082,9 @@ async function init() { closeLabelFilterIfClickOutside(e.target); closeCardLabelSelectorIfClickOutside(e.target); + if (typeof closeCardDatePopoverIfClickOutside === 'function') { + closeCardDatePopoverIfClickOutside(e.target); + } if (typeof closeBoardViewPopoverIfClickOutside === 'function') { closeBoardViewPopoverIfClickOutside(e.target); } diff --git a/app/lists/listActionsPopover.js b/app/lists/listActionsPopover.js index 07459f5..8a55884 100644 --- a/app/lists/listActionsPopover.js +++ b/app/lists/listActionsPopover.js @@ -114,6 +114,11 @@ function closeListActionsPopover() { state.cardCount = 0; } +function isListActionsPopoverOpen() { + const popover = document.getElementById('listActionsPopover'); + return Boolean(popover && !popover.classList.contains('hidden')); +} + function positionListActionsPopover(anchorElement, popover) { if (!(anchorElement instanceof Element) || !(popover instanceof Element)) { return; diff --git a/docs/README.md b/docs/README.md index 129bd6e..5e42e32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ If you want structured tool access from an agent, see [MCP Server](../MCP_README - Creating and organizing boards, lists, and cards - Drag-and-drop movement, including the empty insertion slot shown while dragging cards -- Start dates, due dates, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting in Table, Planner date views, and External Published Calendar +- Start/due date ranges on Kanban cards, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting in Table, Planner date views, and External Published Calendar - Archiving and restoring cards and lists - Settings, including app-wide tooltips/notifications/Quick Add global shortcut/External Published Calendar and board-specific workflow, colors, labels, and imports - Obsidian integration, including boards stored inside vaults, Open With actions, generated Bases files, linked notes, missing-note handling, linked objects, dropped local-file linking, URL favicons, `signboard://` card/board links, and the optional Obsidian companion plugin diff --git a/docs/codex/FILE_STRUCTURE.md b/docs/codex/FILE_STRUCTURE.md index a6d031c..f4f8645 100644 --- a/docs/codex/FILE_STRUCTURE.md +++ b/docs/codex/FILE_STRUCTURE.md @@ -34,7 +34,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/utilities/cardTimestamps.js` - Renderer card timestamp formatting helpers for editor metadata and Table age columns. - `app/utilities/linkedObjects.js` - Shared renderer helpers for counting structured `linked_objects` and legacy `related` links, plus paperclip count badge creation for Kanban/Table. - `app/appSettings.js` - Renderer app-settings state, app-wide tooltip/notification/Quick Add global shortcut/External Published Calendar controls, persistence scheduling, and one-time migration from legacy board settings. -- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation, new-card label selection helpers, Settings modal board panels/nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. +- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal board panels/nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. - `app/board/boardSearch.js` - Board search state, input handling for title/body filtering, and keyboard navigation from the search field through visible card results. - `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card start/due and open task start/due markers, and source-list/source-board pills on temporal cards. - `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table sort controls, and list-column card moves through the top-of-list move IPC path. @@ -42,7 +42,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/board/archiveBrowser.js` - Dedicated Archive modal UI, search-first archived card/list browsing with keyboard result navigation, detail-pane rendering, incremental result loading, and restore flows. - `app/board/boardTabs.js` - Open-board tab session state (restore/add/close/reorder), keyboard navigation/close behavior for visible tabs, responsive `N more` overflow for unbounded open boards, plus the shared safe board-switch helper used by tab clicks and the switcher. - `app/board/boardSwitcher.js` - Quick board switcher overlay for `Cmd/Ctrl + K`, filtering and closing currently open boards and delegating selected board changes to the shared switch helper. -- `app/cards/createCardElement.js` - Card DOM rendering, task progress and linked-object badge display, list-item/card-title button semantics, and click behavior. +- `app/cards/createCardElement.js` - Card DOM rendering, compact start/due date metadata popover, task progress and linked-object badge display, list-item/card-title button semantics, and click behavior. - `app/cards/processAddNewCard.js` - New card creation flow, including open-board targeting and optional create-and-open behavior. - `app/cards/processAddNewList.js` - New list creation flow. - `app/lists/listActionsPopover.js` - List action popover rendering for adding cards/lists, moving lists left/right, archiving cards/lists, keyboard option navigation, shortcut hints, and status announcements. @@ -86,7 +86,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `scripts/test-frontmatter.js` - Node assertions for frontmatter behavior. - `scripts/test-board-labels.js` - Node assertions for board label settings defaults/migration/filter logic. - `scripts/test-app-settings.js` - Node assertions for app-wide settings persistence and one-time board-settings migration. -- `scripts/test-board-card-metadata.js` - Board card metadata rendering assertions (due/labels/task badge behavior). +- `scripts/test-board-card-metadata.js` - Board card metadata rendering assertions (compact start/due ranges, labels, task badge behavior). - `scripts/test-board-views.js` - Kanban/Table/Planner rendering and filter helper assertions. - `scripts/test-card-timestamps.js` - Card timestamp normalization assertions for frontmatter, activity, and filesystem fallback behavior. - `scripts/test-archive.js` - Archive metadata, archive-browser data, restore flow, empty archived-list cleanup, and legacy archive fallback assertions. diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 649c06c..4473071 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -204,6 +204,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - `app/cards/createCardElement.js`: - Reads card frontmatter/body preview. - Computes task summary + task start/due dates from card body checklist lines. + - Shows card-level start/due dates through one compact Dates metadata control and body-level popover. - Shows task progress badge on board cards. - Shows linked-object count badges on board cards. - Shows label chips and a tag-icon picker on each card. @@ -213,12 +214,13 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Owns board label state in the renderer. - Renders the header filter dropdown with mutually exclusive `Today` / `Overdue` / next-range date filters plus multi-select OR label filters. - Keeps the header filter popover, card label popover, and Settings section nav keyboard-operable with arrow keys, `Home`, `End`, and opener focus restoration on popover `Esc`. + - Provides a card-label popover gear shortcut that opens the board's Labels settings panel. - Evaluates date filters from card start/due dates and incomplete task start/due markers, ignoring completed task date markers. - Combines date filters, label filters, and board search with AND logic when determining visibility. - Owns board workflow settings for completed-list auto-detection, ignored auto-detected lists, and manual completed-list selection. - Keeps filter state temporary only; opening or switching boards resets the active date + label filters. - Keeps the filter toolbar button icon-only and applies an accent-tinted active state when any filter is set; active summary text lives in tooltip/ARIA copy. - - Handles card label popovers with inline label creation, new-card label selection, Settings modal board panels, and the board import UI/actions. + - Handles card label popovers with inline label creation and a Labels settings shortcut, new-card label selection, Settings modal board panels, and the board import UI/actions. - Persists board labels through preload APIs. - `app/board/boardSearch.js`: - Stores the current search query/tokens. @@ -231,7 +233,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Quick Add card submissions can target any currently open/trusted board, request opening the created card immediately with the notes field focused, and switch to the target board first when `Shift + Enter` creates a card outside the active board. - `app/modals/toggleEditCardModal.js`: - Loads card into OverType editor. - - Displays and edits card-level start/due dates from frontmatter. + - Displays and edits card-level start/due dates from frontmatter with separate editor controls. - Displays quiet `Created` and `Updated` card timestamps from the normalized desktop read metadata. - Saves title/body/frontmatter through `window.board.writeCard`. - Debounces editor body writes and serializes save order to prevent stale overwrite races. diff --git a/docs/using-signboard.md b/docs/using-signboard.md index 96f19b2..d594918 100644 --- a/docs/using-signboard.md +++ b/docs/using-signboard.md @@ -101,7 +101,7 @@ Click a card to open it. In the card editor you can: - Rename the card - Edit the Markdown body -- Set a due date +- Set start and due dates - Add or remove labels - Move the card to another list - Move it to the next list @@ -160,7 +160,7 @@ These features are what make cards show up in Planner and filters. Every card can have a start date in addition to a due date. Use a start date when work is scheduled to begin or become actionable before it is due. -Start dates appear on cards, in Table's `Start` column, in Planner date views, and in date-aware filters. +Start dates appear on cards, in Table's `Start` column, in Planner date views, and in date-aware filters. On Kanban cards, start and due dates share one compact `Dates` control. Click the clock icon or date range on a card to open both fields. ### Card due dates @@ -168,7 +168,7 @@ Every card can have a due date. Once a card has a due date, it becomes visible in: -- due date displays on the card +- date displays on the card - due-date-aware filters - Planner - daily due notifications if enabled in app settings @@ -191,7 +191,7 @@ Task dates are separate from the cardโ€™s main start and due dates. Open checkli ### Labels -Labels are defined per board. Add them in `Settings > Labels`, or create a new label directly from the label picker while editing or creating a card. +Labels are defined per board. Add them in `Settings > Labels`, or create a new label directly from the label picker while editing or creating a card. The label picker also has a gear button that opens the board's Labels settings for renaming labels or changing colors. Labels are useful for: @@ -227,7 +227,7 @@ Board context can be Kanban or Table. Dated planning happens in Planner. ### Kanban -Kanban is the board view. Use it for day-to-day drag-and-drop organization. Cards show compact metadata for start dates, due dates, checklist progress, labels, and linked-object counts. +Kanban is the board view. Use it for day-to-day drag-and-drop organization. Cards show compact metadata for start/due date ranges, checklist progress, labels, and linked-object counts. ### Table diff --git a/readme.md b/readme.md index 1eb9692..db588dd 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +17,7 @@ Signboard is free for personal use. If you are using Signboard for your work it - ๐Ÿ–Œ๏ธ Color scheme per board (several to choose from!) - ๐ŸŒ™ Light and dark mode variants for all color schemes - ๐Ÿท Custom labels per board -- ๐Ÿ—“ Card start/due dates and task list item start/due dates +- ๐Ÿ—“ Compact card start/due date ranges and task list item start/due dates - ๐Ÿ“‹ Kanban and Table board views, including card age columns and sorting in Table - ๐Ÿ—‚ Planner overlay for actionable dated work across open boards - ๐Ÿ“† Optional local External Published Calendar feed for calendar app subscriptions diff --git a/scripts/test-board-card-metadata.js b/scripts/test-board-card-metadata.js index 0d67965..9a1f1df 100644 --- a/scripts/test-board-card-metadata.js +++ b/scripts/test-board-card-metadata.js @@ -214,23 +214,33 @@ function createContext(cardFactory, callbacks, options = {}) { ? options.getCardFilterDueDates : (cardDueDateValue, taskDueDates = []) => { const dueDates = new Set(); - if (cardDueDateValue) { - dueDates.add(String(cardDueDateValue)); + const addDate = (dateValue) => { + if (dateValue) { + dueDates.add(String(dateValue)); + } + }; + if (Array.isArray(cardDueDateValue)) { + cardDueDateValue.forEach(addDate); + } else { + addDate(cardDueDateValue); } for (const taskDueDate of Array.isArray(taskDueDates) ? taskDueDates : []) { - dueDates.add(String(taskDueDate)); + addDate(taskDueDate); } return [...dueDates]; }; const getActiveBoardFilterDueDates = typeof options.getActiveBoardFilterDueDates === 'function' ? options.getActiveBoardFilterDueDates : (cardDueDateValue, taskDueDates = []) => getCardFilterDueDates(cardDueDateValue, taskDueDates); + const formatDueDate = typeof options.formatDueDate === 'function' + ? options.formatDueDate + : async (dueDate) => `Formatted ${dueDate}`; const context = { window: { board: { readCard: async () => cardFactory(), - formatDueDate: async (dueDate) => `Formatted ${dueDate}`, + formatDueDate, updateFrontmatter: async () => {}, }, }, @@ -306,27 +316,18 @@ async function run() { const cardNoMetadata = await contextNoMetadata.createCardElement('/tmp/card-a.md'); const metadataNoMetadata = findFirstByClass(cardNoMetadata, 'metadata'); - const dueButtonNoMetadata = findFirstByClass(cardNoMetadata, 'due-date-action'); + const dateButtonNoMetadata = findFirstByClass(cardNoMetadata, 'card-date-action'); const labelButtonNoMetadata = findFirstByClass(cardNoMetadata, 'card-label-button'); assert(metadataNoMetadata, 'expected card metadata container'); - assert(dueButtonNoMetadata, 'expected due date button'); + assert(dateButtonNoMetadata, 'expected date button'); assert(labelButtonNoMetadata, 'expected label button'); assert(metadataNoMetadata.classList.contains('metadata-discovery')); - assert(dueButtonNoMetadata.classList.contains('metadata-action-empty')); + assert(dateButtonNoMetadata.classList.contains('metadata-action-empty')); assert(labelButtonNoMetadata.classList.contains('metadata-action-empty')); - assert.strictEqual(dueButtonNoMetadata.tagName, 'BUTTON'); - assert.strictEqual(dueButtonNoMetadata.getAttribute('aria-label'), 'Set due date'); - - const dueClick = clickEvent(); - await dueButtonNoMetadata.dispatch('click', dueClick); - assert.strictEqual(dueClick.preventDefaultCalled, true); - assert.strictEqual(dueClick.stopPropagationCalled, true); - assert.strictEqual(dueDateCalls.length, 1); - assert.strictEqual(Boolean(dueDateCalls[0][0]), true); - assert.strictEqual(dueDateCalls[0][0].triggerElement, dueButtonNoMetadata); - assert.strictEqual(dueDateCalls[0][0].dueDateValue, ''); - assert.strictEqual(typeof dueDateCalls[0][0].onSelect, 'function'); + assert.strictEqual(dateButtonNoMetadata.tagName, 'BUTTON'); + assert.strictEqual(dateButtonNoMetadata.getAttribute('aria-label'), 'Set dates'); + assert.strictEqual(dueDateCalls.length, 0); const labelClick = clickEvent(); await labelButtonNoMetadata.dispatch('click', labelClick); @@ -352,14 +353,49 @@ async function run() { ); const cardDueOnly = await contextDueOnly.createCardElement('/tmp/card-b.md'); - const dueButtonDueOnly = findFirstByClass(cardDueOnly, 'due-date-action'); + const dateButtonDueOnly = findFirstByClass(cardDueOnly, 'card-date-action'); const labelButtonDueOnly = findFirstByClass(cardDueOnly, 'card-label-button'); - assert(dueButtonDueOnly, 'expected due button on due-date card'); + assert(dateButtonDueOnly, 'expected date button on due-date card'); assert(labelButtonDueOnly, 'expected label button on due-date card'); - assert.strictEqual(dueButtonDueOnly.classList.contains('metadata-action-empty'), false); + assert.strictEqual(dateButtonDueOnly.classList.contains('metadata-action-empty'), false); assert.strictEqual(labelButtonDueOnly.classList.contains('metadata-action-empty'), true); - assert(dueButtonDueOnly.textContent.includes('Formatted 2026-03-14')); + assert(dateButtonDueOnly.textContent.includes('Due Formatted 2026-03-14')); + assert.strictEqual(dateButtonDueOnly.getAttribute('aria-label'), 'Due Formatted 2026-03-14. Change dates.'); + + const contextDateRange = createContext( + () => ({ + frontmatter: { + title: 'Card Date Range', + labels: [], + start: '2026-03-10', + due: '2026-03-14', + }, + body: 'Body', + }), + { + toggleCardLabelSelector: () => {}, + toggleEditCardModal: async () => {}, + openDueDatePickerAtTrigger: () => {}, + }, + { + formatDueDate: async (dateValue) => ({ + '2026-03-10': 'Mar 10', + '2026-03-14': 'Mar 14', + }[dateValue] || String(dateValue || '')), + }, + ); + + const cardDateRange = await contextDateRange.createCardElement('/tmp/card-range.md'); + const dateButtonDateRange = findFirstByClass(cardDateRange, 'card-date-action'); + + assert(dateButtonDateRange, 'expected date button on ranged card'); + assert.strictEqual(dateButtonDateRange.classList.contains('metadata-action-empty'), false); + assert(dateButtonDateRange.textContent.includes('Mar 10-14')); + assert.strictEqual( + dateButtonDateRange.getAttribute('aria-label'), + 'Dates Mar 10 through Mar 14. Change dates.', + ); const contextLabelOnly = createContext( () => ({ @@ -379,14 +415,14 @@ async function run() { const cardLabelOnly = await contextLabelOnly.createCardElement('/tmp/card-c.md'); const metadataLabelOnly = findFirstByClass(cardLabelOnly, 'metadata'); - const dueButtonLabelOnly = findFirstByClass(cardLabelOnly, 'due-date-action'); + const dateButtonLabelOnly = findFirstByClass(cardLabelOnly, 'card-date-action'); const labelButtonLabelOnly = findFirstByClass(cardLabelOnly, 'card-label-button'); assert(metadataLabelOnly, 'expected label-only metadata'); - assert(dueButtonLabelOnly, 'expected due button on label-only card'); + assert(dateButtonLabelOnly, 'expected date button on label-only card'); assert(labelButtonLabelOnly, 'expected label button on label-only card'); assert.strictEqual(metadataLabelOnly.classList.contains('metadata-discovery'), false); - assert.strictEqual(dueButtonLabelOnly.classList.contains('metadata-action-empty'), true); + assert.strictEqual(dateButtonLabelOnly.classList.contains('metadata-action-empty'), true); assert.strictEqual(labelButtonLabelOnly.classList.contains('metadata-action-empty'), false); assert.strictEqual(labelButtonLabelOnly.getAttribute('aria-label'), 'Edit labels'); diff --git a/static/styles.css b/static/styles.css index 30b579d..8f7b72e 100644 --- a/static/styles.css +++ b/static/styles.css @@ -2200,21 +2200,24 @@ body.board-card-drag-active * { pointer-events: auto; } +.card-date-action, .due-date-action { display: inline-flex; align-items: center; margin-right: 4px; } +.card-date-action .card-date-label, .due-date-action .formatted-date { vertical-align: top; + white-space: nowrap; } -.due-date-action.due-date-today { +.metadata-action.due-date-today { color: var(--due-date-today-color); } -.due-date-action.due-date-tomorrow { +.metadata-action.due-date-tomorrow { color: var(--due-date-tomorrow-color); } @@ -2425,6 +2428,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, .card > .card-drag-frame, .list, .modal, + .card-date-popover, .label-popover { border: 1px solid CanvasText; box-shadow: none; @@ -4186,6 +4190,150 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, z-index: 11000; } +.card-label-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + padding: 0 2px 6px; +} + +.card-label-popover-title { + font-size: var(--font-sm); + font-weight: 650; +} + +.card-label-settings-shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.card-label-settings-shortcut:hover { + border-color: var(--border); + color: var(--text); +} + +.card-label-settings-shortcut svg { + width: 15px; + height: 15px; +} + +.card-date-popover { + position: fixed; + z-index: 12030; + width: min(260px, calc(100vw - 16px)); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + box-shadow: 0 12px 30px var(--shadow-card); + padding: 8px; +} + +.card-date-popover-content { + display: flex; + flex-direction: column; + gap: 6px; +} + +.card-date-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + padding: 0 2px 4px; +} + +.card-date-popover-title { + font-size: var(--font-sm); + font-weight: 650; +} + +.card-date-popover-close, +.card-date-popover-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.card-date-popover-close:hover, +.card-date-popover-clear:hover { + border-color: var(--border); + color: var(--text); +} + +.card-date-popover-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px; + align-items: center; + gap: 6px; +} + +.card-date-popover-row:not(.has-clear) { + grid-template-columns: minmax(0, 1fr); +} + +.card-date-popover-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + min-height: 36px; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + box-shadow: none; + text-align: left; +} + +.card-date-popover-field:hover { + border-color: var(--border); + background: color-mix(in oklab, var(--bg-card) 92%, var(--border)); +} + +.card-date-popover-field-label { + flex: 1; + min-width: 0; + color: var(--text); + font-size: var(--font-sm); +} + +.card-date-popover-field-value { + flex: 0 1 auto; + min-width: 0; + color: var(--muted); + font-size: var(--font-xs); + white-space: nowrap; +} + +.card-date-popover-field-value.is-empty { + color: color-mix(in oklab, var(--muted) 76%, var(--bg-card)); +} + .list-actions-popover { position: fixed; z-index: 11010; @@ -4788,6 +4936,7 @@ select:disabled { border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 14px 34px var(--shadow-card); + z-index: 12070; } .sb-themed-fdatepicker .fdatepicker-header { diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index b06cd0d..7bdf9a6 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -317,6 +317,72 @@ async function openCardInEditor(page, listIndex, cardIndex = 0) { await expect(page.locator('#cardEditorOverType .overtype-input')).toBeVisible(); } +async function closeCardClickProbeUi(page, state) { + if (state.editorOpen) { + await page.locator('#cardEditorClose').click(); + await expect(page.locator('#modalEditCard')).toBeHidden(); + return; + } + + if (state.datePickerOpen || state.labelPopoverOpen || state.cardDatePopoverOpen) { + await page.keyboard.press('Escape'); + await page.evaluate(() => { + if (typeof destroyActiveDueDatePicker === 'function') { + destroyActiveDueDatePicker(); + } + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } + if (typeof closeCardLabelSelector === 'function') { + closeCardLabelSelector(); + } + }); + } +} + +async function getCardClickProbeState(page) { + return await page.evaluate(() => { + const modalEditCard = document.getElementById('modalEditCard'); + const editorOpen = Boolean( + modalEditCard && + !modalEditCard.classList.contains('hidden') && + modalEditCard.getAttribute('aria-hidden') !== 'true' && + modalEditCard.style.display !== 'none' + ); + + return { + editorOpen, + datePickerOpen: Boolean(document.querySelector('.sb-themed-fdatepicker, .flatpickr-calendar.open, .flatpickr-calendar.inline')), + cardDatePopoverOpen: Boolean(document.querySelector('.card-date-popover')), + labelPopoverOpen: Boolean(document.querySelector('.card-label-selector-popover, .label-popover')), + }; + }); +} + +async function getCardClickProbeTarget(page, point) { + return await page.evaluate(({ x, y }) => { + const element = document.elementFromPoint(x, y); + const describe = (target) => { + if (!(target instanceof Element)) { + return null; + } + + return { + tagName: target.tagName.toLowerCase(), + id: target.id || '', + className: typeof target.className === 'string' ? target.className : '', + }; + }; + + return { + element: describe(element), + button: describe(element && element.closest('button')), + metadataAction: describe(element && element.closest('.metadata-action')), + card: describe(element && element.closest('.card')), + }; + }, point); +} + async function setEditorBody(page, body) { await page.locator('#cardEditorOverType .overtype-input').evaluate((element, nextBody) => { element.value = String(nextBody || ''); @@ -396,6 +462,67 @@ test('keeps add modals hidden on startup', async ({ page }) => { await expect(page.locator('#boardMenuPopover')).toBeHidden(); }); +test('responds to sampled card surface clicks immediately after startup', async ({ page }) => { + const card = page.locator('.list').first().locator('.card').first(); + await expect(card).toBeVisible(); + + const box = await card.boundingBox(); + expect(box).toBeTruthy(); + + const horizontalSamples = [0.02, 0.16, 0.33, 0.5, 0.67, 0.84, 0.98]; + const verticalSamples = [0.04, 0.22, 0.5, 0.78, 0.96]; + const misses = []; + + for (const yRatio of verticalSamples) { + for (const xRatio of horizontalSamples) { + const point = { + x: box.x + Math.max(2, Math.min(box.width - 2, box.width * xRatio)), + y: box.y + Math.max(2, Math.min(box.height - 2, box.height * yRatio)), + }; + const roundedPoint = { + x: Math.round(point.x), + y: Math.round(point.y), + }; + const target = await getCardClickProbeTarget(page, point); + + await page.mouse.click(point.x, point.y); + const state = await getCardClickProbeState(page); + + if (!state.editorOpen && !state.datePickerOpen && !state.cardDatePopoverOpen && !state.labelPopoverOpen) { + misses.push({ + point: roundedPoint, + ratio: { + x: xRatio, + y: yRatio, + }, + target, + }); + } + + await closeCardClickProbeUi(page, state); + } + } + + expect(misses, JSON.stringify(misses, null, 2)).toEqual([]); +}); + +test('opens a card when a startup board refresh lands between pointer down and up', async ({ page }) => { + const card = page.locator('.list').first().locator('.card').first(); + await expect(card).toBeVisible(); + + const box = await card.boundingBox(); + expect(box).toBeTruthy(); + + await page.mouse.move(box.x + (box.width / 2), box.y + (box.height / 2)); + await page.mouse.down(); + await page.evaluate(async () => { + await renderBoard(); + }); + await page.mouse.up(); + + await expect(page.locator('#modalEditCard')).toBeVisible(); +}); + test('installs native application menu actions', async ({ electronApp }) => { const snapshot = await electronApp.evaluate(({ Menu, app }) => { const appMenu = Menu.getApplicationMenu(); @@ -883,15 +1010,143 @@ test('does not throw when formatting invalid due date values', async ({ page }) }); }); +test('renders card start and due dates as a compact date range', async ({ page, boardRoot }) => { + const cardPath = path.join(boardRoot, '000-To-do-stock', '000-plan-release-stock.md'); + + await cardFrontmatter.updateFrontmatter(cardPath, { + start: '2026-06-17', + due: '2026-06-26', + }); + await page.evaluate(async () => { + await renderBoard(); + }); + + const firstCard = page.locator('.list').first().locator('.card').first(); + const dateButton = firstCard.locator('.card-date-action'); + await expect(dateButton).toContainText('Jun 17-26'); + await expect(dateButton).toHaveAttribute('aria-label', 'Dates Jun 17 through Jun 26. Change dates.'); + + await dateButton.click(); + const datePopover = page.locator('.card-date-popover'); + await expect(datePopover).toBeVisible(); + await expect(datePopover.getByRole('button', { name: 'Change start date' })).toContainText('Jun 17'); + await expect(datePopover.getByRole('button', { name: 'Change due date' })).toContainText('Jun 26'); +}); + +test('keeps the card date popover open during a pending board refresh', async ({ page }) => { + const firstCard = page.locator('.list').first().locator('.card').first(); + await firstCard.hover(); + + await firstCard.locator('.card-date-action').click(); + const datePopover = page.locator('.card-date-popover'); + await expect(datePopover).toBeVisible(); + + const popoverBox = await datePopover.boundingBox(); + expect(popoverBox).toBeTruthy(); + + await page.mouse.move( + popoverBox.x + (popoverBox.width / 2), + popoverBox.y + (popoverBox.height / 2), + ); + + await page.evaluate(async () => { + await runExternalBoardRefresh(); + }); + + await page.mouse.move( + popoverBox.x + popoverBox.width + 24, + popoverBox.y + (popoverBox.height / 2), + ); + + await expect(datePopover).toBeVisible(); +}); + +test('keeps the card label picker open during a pending board refresh', async ({ page }) => { + const firstCard = page.locator('.list').first().locator('.card').first(); + await firstCard.hover(); + + await firstCard.locator('.card-label-button').click(); + const labelPopover = page.locator('.card-label-popover'); + await expect(labelPopover).toBeVisible(); + + const popoverBox = await labelPopover.boundingBox(); + expect(popoverBox).toBeTruthy(); + + await page.mouse.move( + popoverBox.x + (popoverBox.width / 2), + popoverBox.y + (popoverBox.height / 2), + ); + + await page.evaluate(async () => { + await runExternalBoardRefresh(); + }); + + await page.mouse.move( + popoverBox.x + popoverBox.width + 24, + popoverBox.y + (popoverBox.height / 2), + ); + + await expect(labelPopover).toBeVisible(); +}); + +test('keeps the list actions popover open during a pending board refresh', async ({ page }) => { + const firstList = page.locator('.list').first(); + await expect(firstList).toBeVisible(); + + await firstList.locator('.list-actions-button').click(); + const listActionsPopover = page.locator('#listActionsPopover'); + await expect(listActionsPopover).toBeVisible(); + + const popoverBox = await listActionsPopover.boundingBox(); + expect(popoverBox).toBeTruthy(); + + await page.mouse.move( + popoverBox.x + (popoverBox.width / 2), + popoverBox.y + (popoverBox.height / 2), + ); + + await page.evaluate(async () => { + await runExternalBoardRefresh(); + }); + + await page.mouse.move( + popoverBox.x + popoverBox.width + 24, + popoverBox.y + (popoverBox.height / 2), + ); + + await expect(listActionsPopover).toBeVisible(); +}); + +test('opens board label settings from the card label picker shortcut', async ({ page }) => { + const firstCard = page.locator('.list').first().locator('.card').first(); + await firstCard.hover(); + + await firstCard.locator('.card-label-button').click(); + const labelPopover = page.locator('.card-label-popover'); + await expect(labelPopover).toBeVisible(); + + await labelPopover.getByRole('button', { name: 'Open label settings' }).click(); + + await expect(labelPopover).toBeHidden(); + await expect(page.locator('#modalBoardSettings')).toBeVisible(); + await expect(page.locator('#boardSettingsNavLabels')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#boardSettingsPanelLabels')).toHaveAttribute('aria-hidden', 'false'); + await expect(page.locator('#boardSettingsLabels .board-settings-label-name').first()).toBeFocused(); +}); + test('closes the card date picker before opening the label picker', async ({ page }) => { const firstCard = page.locator('.list').first().locator('.card').first(); await firstCard.hover(); - await firstCard.locator('.due-date-action').click(); + await firstCard.locator('.card-date-action').click(); + const datePopover = page.locator('.card-date-popover'); + await expect(datePopover).toBeVisible(); + await datePopover.getByRole('button', { name: 'Set due date' }).click(); await expect(page.locator('.sb-themed-fdatepicker')).toBeVisible(); await firstCard.locator('.card-label-button').click(); await expect(page.locator('.sb-themed-fdatepicker')).toBeHidden(); + await expect(datePopover).toBeHidden(); await expect(page.locator('.card-label-popover')).toBeVisible(); }); From 280e8b753169f079e2d9d64d1a520d1acfce1453 Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Mon, 22 Jun 2026 20:58:05 -0400 Subject: [PATCH 03/10] Added AI tools. Updated MCP and CLI to make agents more efficient at finding boards. --- AGENTS.md | 6 +- CODEX.md | 14 +- MCP_README.md | 14 +- app/appSettings.js | 694 +++++++++++++ app/board/boardLabels.js | 193 +++- app/board/boardTabs.js | 18 +- app/init.js | 3 + app/modals/closeAllModals.js | 7 + app/modals/toggleEditCardModal.js | 614 ++++++++++++ docs/README.md | 6 +- docs/codex/FILE_STRUCTURE.md | 29 +- docs/codex/PROJECT_CONTEXT.md | 26 +- docs/signboard-cli.md | 25 +- ...ignboard-feature-showcase-video-outline.md | 12 +- docs/using-signboard.md | 44 +- index.html | 140 ++- lib/aiTaskSuggestions.js | 923 ++++++++++++++++++ lib/appSettings.js | 243 ++++- lib/boardDiscovery.js | 434 ++++++++ lib/cliApp.js | 64 +- lib/mcpServer.js | 46 + main.js | 263 +++++ package.json | 1 + preload.js | 4 + readme.md | 6 +- scripts/test-ai-task-suggestions.js | 333 +++++++ scripts/test-app-settings.js | 87 ++ scripts/test-cli.js | 34 + scripts/test-mcp-server.js | 56 +- skills/signboard-mcp/SKILL.md | 12 +- static/styles.css | 256 ++++- tests/playwright/signboard-smoke.spec.js | 164 ++++ 32 files changed, 4642 insertions(+), 129 deletions(-) create mode 100644 lib/aiTaskSuggestions.js create mode 100644 lib/boardDiscovery.js create mode 100644 scripts/test-ai-task-suggestions.js diff --git a/AGENTS.md b/AGENTS.md index cb54d9c..2e3c424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,8 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Keep native app menu actions and the Playwright native-menu regression aligned when changing menu behavior. - Defer DOM/layout mutations after macOS native menu or `<select>` popup interactions with `waitForNativeMenuTrackingToSettle()` before replacing, disabling, rerendering, or moving controls. - Keep card drag/drop clone and placeholder behavior in sync with `app/utilities/cardDragTilt.js` and `static/styles.css`. -- MCP allowed roots include both explicit MCP roots and desktop trusted board roots; keep root loading, `get_config`, and board-name resolution tests aligned. -- CLI and MCP board creation should stay aligned: `signboard boards create` and `signboard_create_board` scaffold the same default lists and starter card. +- MCP allowed roots include both explicit MCP roots and desktop trusted board roots; keep root loading, `get_config`, `signboard_list_boards`, and board-name resolution tests aligned. +- CLI and MCP board discovery/creation should stay aligned: `signboard boards list` and `signboard_list_boards` report the same known-board metadata shape where applicable, while `signboard boards create` and `signboard_create_board` scaffold the same default lists and starter card. - Quick board switching uses `Cmd/Ctrl + K`, searches all currently open boards, and should keep tab switching, overflow tab switching, and switcher switching on the same safe board-switch helper. - Planner is the workspace-level home for Calendar, This Week, Day, and Agenda, including all-open-board and current-board shortcut scopes; keep `app/board/plannerView.js`, Kanban/Table board rendering, search/filter behavior, and shortcut docs aligned. - Workspace-level keyboard shortcuts close the active card editor before changing context; editor-scoped card move/archive shortcuts should keep acting on the open card. @@ -28,6 +28,8 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Obsidian integration writes flat Obsidian-friendly card properties, detects containing vaults by walking upward for `.obsidian`, auto-creates/updates managed `Signboard Board.base` files without overwriting user-customized Bases, supports metadata-only linked Obsidian notes named `Linked Signboard Note.md` from the card editor only for cards inside detected vaults, shows the Obsidian-vault-required info modal for linked-note/Base actions outside a vault, and resolves `signboard://open-card` links only through trusted board roots. `signboard://open-board?path=...` opens validated vault-contained board folders after confirmation. The optional Obsidian companion plugin lives in `obsidian-plugin/` and can create/open Signboard boards from Obsidian. Linked objects use structured `linked_objects` frontmatter for Obsidian notes, local files/folders, web URLs, app deep links, and Signboard links; local files can be linked by picker or by dragging files onto the card editor, and Kanban/Table linked-object counts use `app/utilities/linkedObjects.js`. Raw web URLs in the card body stay as Markdown text but are visually marked in the OverType preview and opened through `window.electronAPI.openExternal` from the inline open button or Cmd/Ctrl-click. Keep `lib/obsidianIntegration.js`, `main.js`, preload, renderer menus/views, plugin helpers, and docs aligned. - The sponsorship modal opens from the Board menu "Sponsor" action, About modal action, and fixed bottom-right "Sponsor" pill; the pill hides on compact windows to avoid covering board lists. - Quick Add card creation supports board/list selection across open boards, an optional app-level global shortcut while Signboard is running, and `Shift + Enter` to create, immediately open, and focus the notes field on the new card. +- Settings groups app-wide panels as Settings, Notifications, and Smart Actions, followed by current-board panels ordered General, Labels, Appearance, Workflow, Obsidian, and Import. +- AI assistance is opt-in in App Settings. Ollama settings and Smart Card Action prompts live in app settings, main-process AI requests run through `lib/aiTaskSuggestions.js`, settings verify the configured Ollama URL through `/api/tags` and load installed models into a refreshable dropdown, and the Card Editor floating Smart Card Actions button must preview generated titles, task lists, smart paste Markdown, and custom-action output before changing a card. - Card `start` frontmatter and task `(start: YYYY-MM-DD)` / `(scheduled: YYYY-MM-DD)` markers feed Kanban, Table, Planner, CLI, MCP, and importers alongside due dates; completed checklist date markers and completed-list cards stay non-actionable by default. - Kanban cards show card-level `start` and `due` through one compact Dates control and body-level popover; the card editor keeps separate start/due controls. - Card label popovers can create board labels inline, open the board's Labels settings from a gear shortcut, and Add Card/Quick Add can preselect active-board labels before creating a card. diff --git a/CODEX.md b/CODEX.md index f4cf17c..1aecd6b 100644 --- a/CODEX.md +++ b/CODEX.md @@ -12,9 +12,9 @@ Start here before opening source files. - Obsidian outbound integration lives in `lib/obsidianIntegration.js` plus `main.js` IPC/protocol handlers: cards get flat Obsidian-friendly properties on create/write/move, `signboard://open-card?id=...` resolves only inside trusted board roots, `signboard://open-board?path=...` can open validated vault-contained board folders after confirmation, and boards inside detected vaults auto-create/update a managed `Signboard Board.base` unless the user customizes it. The optional Obsidian companion plugin source lives in `obsidian-plugin/`. - External Published Calendar is an opt-in app setting served by a main-process HTTP server bound to `127.0.0.1`; it publishes an iCalendar feed of trusted-board card due dates and incomplete task due markers, excluding completed workflow lists and board-level opt-outs. - `main.js` also supports headless MCP mode via `--mcp-server` for local agent integration over stdio; implementation lives in `lib/mcpServer.js`. -- MCP board-scoped tools use the union of `SIGNBOARD_MCP_ALLOWED_ROOTS` and the desktop app's trusted board roots from `trusted-board-roots.json`; empty allowed roots leave only non-board config/listing tools available when no trusted roots exist. +- MCP board-scoped tools use the union of `SIGNBOARD_MCP_ALLOWED_ROOTS` and the desktop app's trusted board roots from `trusted-board-roots.json`; empty allowed roots leave only non-board config/listing tools available when no trusted roots exist. Agents should call `signboard_list_boards` before board-scoped MCP work to get known board roots, desktop-open/active flags, trusted-root flags, and bounded allowed-root scan results. - MCP board-name resolution searches configured/trusted roots and also matches the root directory itself, so trusted board roots can be actual board folders rather than only parent folders. -- Signboard MCP includes board-name resolution (`signboard_resolve_board_by_name`), archive browse/read/restore tools (`signboard_list_archive_entries`, `signboard_read_archive_entry`, `signboard_restore_archived_card`, `signboard_restore_archived_list`, `signboard_archive_list`), Trello/Obsidian/Tasks.md import tools, and supports both header-framed + newline-delimited stdio JSON-RPC; dotted `signboard.*` names remain accepted as legacy aliases. +- Signboard MCP includes board discovery (`signboard_list_boards`), board-name resolution (`signboard_resolve_board_by_name`), archive browse/read/restore tools (`signboard_list_archive_entries`, `signboard_read_archive_entry`, `signboard_restore_archived_card`, `signboard_restore_archived_list`, `signboard_archive_list`), Trello/Obsidian/Tasks.md import tools, and supports both header-framed + newline-delimited stdio JSON-RPC; dotted `signboard.*` names remain accepted as legacy aliases. - Main window stability guards are in `main.js` (`unresponsive` dialog + renderer crash recovery window recreate). - Installed packaged CLI shims should run `app.asar/bin/signboard.js` with `ELECTRON_RUN_AS_NODE` rather than launching the desktop app executable lifecycle for each terminal command. - Renderer right-click text editing context menus are owned by `main.js` via the `webContents` `context-menu` event, so editable fields such as the card title and OverType notes editor keep native cut/copy/paste/select-all behavior. @@ -28,7 +28,8 @@ Start here before opening source files. - Board rendering supports Kanban by default plus a board-scoped Table view for scanning cards; Calendar, This Week, Day, and Agenda dated workflows live in Planner. - Planner is managed in `app/board/plannerView.js`; it appears as a left-edge overlay only when at least one board tab is open, defaults to all open boards, has a quick current-board scope toggle, and owns Calendar, This Week, Day, and Agenda views. - Planner search/filtering is separate from board search: it searches card title/body plus board/list source text, filters by `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days`, completed-card visibility, and selected open boards, and exposes label filtering only when scoped to the active board. -- App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut and External Published Calendar settings are stored only in app settings. +- App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut, AI assistance/Ollama settings, and External Published Calendar settings are stored only in app settings. +- AI assistance is opt-in in App Settings. The first provider is Ollama, called from the main process through `lib/aiTaskSuggestions.js`; settings verify the configured Ollama URL through `/api/tags`, populate a local-model dropdown with refresh support, store customizable Smart Card Action prompts, and the Card Editor floating Smart Card Actions button previews generated titles, generated task lists, smart paste Markdown, and custom-action output before changing a card. Card context is sent only when an action is used. - Board-level workflow settings in `board-settings.md` auto-detect completed lists by name (`Done`, `Completed`, `Complete`, `Closed`, `Finished`, `Resolved`, `Shipped`) with manual overrides; Planner, board date filters, and due notifications hide completed-list cards by default while preserving due dates. - Planner uses the default Signboard palette for the active light/dark mode instead of inheriting the active board color scheme; Planner cards show `Board ยท List` source context with source pills tinted from each card's board color scheme. - Opening a Planner card switches the active board behind the overlay when needed before opening the normal editor so label and list controls remain board-correct. @@ -56,20 +57,21 @@ Start here before opening source files. - Obsidian integration helper coverage is in `scripts/test-obsidian-integration.js` (`npm run test:obsidian-integration`). - Obsidian companion plugin helper coverage is in `scripts/test-obsidian-plugin.js` (`npm run test:obsidian-plugin`). - App settings coverage tests are in `scripts/test-app-settings.js` (`npm run test:app-settings`). +- AI task suggestion helper coverage is in `scripts/test-ai-task-suggestions.js` (`npm run test:ai-task-suggestions`). - External Published Calendar coverage is in `scripts/test-external-published-calendar.js` (`npm run test:external-calendar`). - Due notification coverage tests are in `scripts/test-due-notifications.js` (`npm run test:due-notifications`). - Dedicated user-facing MCP setup docs are in `MCP_README.md`. - Release-facing user and agent docs live in `docs/README.md`, `docs/using-signboard.md`, and `docs/signboard-cli.md`. - Reusable agent skill for MCP usage lives at `skills/signboard-mcp/SKILL.md`. - Skill UI metadata lives at `skills/signboard-mcp/agents/openai.yaml`. -- Board tabs/session state live in renderer localStorage: `openBoardPaths` (open tab order), `activeBoardPath` (active board root), and legacy `boardPath` fallback. +- Board tabs/session state live in renderer localStorage: `openBoardPaths` (open tab order), `activeBoardPath` (active board root), and legacy `boardPath` fallback. The renderer also syncs open/active board state into Electron `userData/open-boards.json` so MCP and CLI discovery can report last-known desktop-open boards. - Open board count is unbounded in session state; the tab strip keeps visible tabs compact, collapses overflow behind an `N more` tab that opens the board switcher, and the switcher can close open boards. - Board label definitions are managed in `board-settings.md` files inside each board folder (runtime data, not repo source). -- Settings includes an app-level panel for tooltips and notifications plus board-specific panels for general board management, workflow, labels, colors, and imports; the renderer wiring lives in `app/board/boardLabels.js`, while the actual import filesystem work lives in `lib/importers/*` through `main.js` IPC. +- Settings groups app-wide panels as Settings, Notifications, and Smart Actions, followed by current-board panels ordered General, Labels, Appearance, Workflow, Obsidian, and Import; the renderer wiring lives in `app/board/boardLabels.js`, while the actual import filesystem work lives in `lib/importers/*` through `main.js` IPC. - The sponsorship modal is opened from the Board menu "Sponsor" action, About modal action, and fixed bottom-right "Sponsor" pill; the pill hides on compact windows to avoid covering board lists. - External import pickers are tokenized in `main.js` and surfaced through `window.chooser.pickImportSources(...)`; renderer code never reads arbitrary external files directly. - Trello, Obsidian, and Tasks.md importer coverage lives in `scripts/test-import-trello.js`, `scripts/test-import-obsidian.js`, and `scripts/test-import-tasksmd.js`. -- The terminal CLI exposes `boards create` for default board scaffolding, a dedicated `archive` namespace (`archive cards`, `archive lists`, `archive read`, `archive restore-card`, `archive restore-list`), and path-based import commands (`signboard import trello --file ...`, `signboard import obsidian --source ...`, `signboard import tasksmd --source ...`); MCP advertises matching board-create/archive/import tools while still accepting dotted `signboard.*` legacy aliases. +- The terminal CLI exposes `boards list` for board discovery, `boards create` for default board scaffolding, a dedicated `archive` namespace (`archive cards`, `archive lists`, `archive read`, `archive restore-card`, `archive restore-list`), and path-based import commands (`signboard import trello --file ...`, `signboard import obsidian --source ...`, `signboard import tasksmd --source ...`); MCP advertises matching board-discovery/board-create/archive/import tools while still accepting dotted `signboard.*` legacy aliases. - CLI card writes include `--start`, template duplication (`cards duplicate`, `cards create --from-card`), section-aware body edits, timestamped note insertion, explicit label clearing, and `--dry-run` previews for card write commands. - CLI due filtering in `lib/cliBoard.js` now defaults `--due overdue` to open task items only, with `--task-status any` available when callers want completed task due markers included. - `AGENTS.md` is the cross-tool compatibility entrypoint and should stay aligned with this file. diff --git a/MCP_README.md b/MCP_README.md index 0102f7f..36a8ba3 100644 --- a/MCP_README.md +++ b/MCP_README.md @@ -23,6 +23,7 @@ By default, the server starts in read-only mode. - optional allowlist for board-scoped tools when the desktop app already has trusted board roots - uses your OS path delimiter (`:` on macOS/Linux, `;` on Windows) - MCP combines these paths with Signboard's desktop trusted board roots from `trusted-board-roots.json` + - `signboard_list_boards` reports known usable boards from desktop-open, desktop-trusted, and configured MCP roots - `boardRoot` arguments must resolve inside one configured or trusted root - import tool `sourcePath` / `sourcePaths` arguments must also resolve inside one configured or trusted root @@ -92,6 +93,7 @@ Use it to standardize how agents call `signboard_*` tools (safety checks, read/w The server currently exposes these tools: - `signboard_get_config` +- `signboard_list_boards` - `signboard_list_board_views` - `signboard_resolve_board_by_name` - `signboard_create_board` (write mode only) @@ -120,7 +122,7 @@ The server currently exposes these tools: `tools/list` advertises underscore tool names. Dotted `signboard.*` names are still accepted as legacy aliases for backward compatibility. Board-scoped tools take absolute `boardRoot` paths, `signboard_create_board` takes an absolute `parentRoot`, and all path inputs reject traversal. -Board settings tools include labels, theme overrides, completed-list workflow settings, and board-level External Published Calendar inclusion. App tooltip, notification, Quick Add, and External Published Calendar server preferences are desktop app settings. +Board settings tools include labels, theme overrides, completed-list workflow settings, and board-level External Published Calendar inclusion. App tooltip, notification, Quick Add, AI assistance/Smart Card Action prompt, and External Published Calendar server preferences are desktop app settings. Import tools also take absolute external source paths, and those paths must resolve inside configured or trusted roots. ## Card Metadata in Card Tool Responses @@ -165,9 +167,15 @@ Returned metadata shape: } ``` -## Board name lookup +## Board discovery and lookup -If you do not want to manually type absolute board paths, use: +Agents should call this first: + +- `signboard_list_boards` + +It returns known board roots plus context flags such as `isOpen`, `isActive`, `isTrusted`, `isAllowed`, and `sources`. Desktop-open state comes from the last synced Signboard window tab state; trusted roots come from the desktop app's persisted trusted board roots; configured MCP roots are scanned with a bounded shallow search for board-looking folders. + +If you know a board name but do not know its absolute path, use: - `signboard_resolve_board_by_name` diff --git a/app/appSettings.js b/app/appSettings.js index cb142ba..db11f11 100644 --- a/app/appSettings.js +++ b/app/appSettings.js @@ -11,6 +11,56 @@ const DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS = Object.freeze({ port: 48273, token: '', }); +const APP_SMART_CARD_ACTION_LABEL_MAX_LENGTH = 80; +const APP_SMART_CARD_ACTION_PROMPT_MAX_LENGTH = 6000; +const APP_CUSTOM_SMART_CARD_ACTION_LIMIT = 12; +const DEFAULT_APP_SMART_CARD_ACTIONS = Object.freeze([ + Object.freeze({ + id: 'generate-title', + type: 'title', + label: 'Generate new title', + prompt: [ + 'Improve the card title using the current title and card body.', + 'Keep it concise, specific, and action-oriented.', + 'Preserve the original intent and do not add facts that are not supported by the card.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'generate-task-list', + type: 'tasks', + label: 'Generate task list', + prompt: [ + 'Generate practical checklist items for this card.', + 'Infer common next actions from the title, body, board, list, and labels.', + 'Do not duplicate existing checklist items.', + 'Use short imperative task text.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'smart-paste', + type: 'paste', + label: 'Smart paste', + prompt: [ + 'Format the pasted information for this Signboard Markdown card.', + 'Preserve the complete useful information from the pasted text, including names, dates, decisions, links, and requirements.', + 'Use clear Markdown sections, a concise summary when helpful, task list items for follow-up work, and reference URLs when present.', + 'Do not invent facts.', + ].join('\n'), + builtIn: true, + }), +]); +const DEFAULT_APP_AI_SETTINGS = Object.freeze({ + enabled: false, + provider: 'ollama', + ollama: Object.freeze({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 6, + }), + smartCardActions: DEFAULT_APP_SMART_CARD_ACTIONS, +}); const DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_STATUS = Object.freeze({ enabled: false, running: false, @@ -18,6 +68,14 @@ const DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_STATUS = Object.freeze({ url: '', message: 'Disabled', }); +const DEFAULT_APP_OLLAMA_MODEL_STATUS = Object.freeze({ + checked: false, + checking: false, + ok: false, + url: '', + models: Object.freeze([]), + message: 'Not checked', +}); function getAppSettingsState() { if (!window.__signboardAppSettingsState) { @@ -27,7 +85,14 @@ function getAppSettingsState() { tooltipsEnabled: DEFAULT_APP_TOOLTIPS_ENABLED, quickAddSettings: { ...DEFAULT_APP_QUICK_ADD_SETTINGS }, externalPublishedCalendarSettings: { ...DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS }, + aiSettings: { + ...DEFAULT_APP_AI_SETTINGS, + ollama: { ...DEFAULT_APP_AI_SETTINGS.ollama }, + smartCardActions: cloneDefaultAppSmartCardActions(), + }, externalPublishedCalendarStatus: { ...DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_STATUS }, + ollamaModelStatus: { ...DEFAULT_APP_OLLAMA_MODEL_STATUS, models: [] }, + ollamaModelStatusRequestId: 0, globalShortcutStatus: { accelerator: '', registered: false, @@ -117,6 +182,183 @@ function normalizeAppExternalPublishedCalendarSettings(calendarSettings) { }; } +function normalizeAppAiProvider(value) { + return value === 'ollama' ? 'ollama' : DEFAULT_APP_AI_SETTINGS.provider; +} + +function normalizeAppOllamaUrl(value) { + let candidate = String(value || '').trim(); + if (!candidate) { + candidate = DEFAULT_APP_AI_SETTINGS.ollama.url; + } + + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { + candidate = `http://${candidate}`; + } + + try { + const parsed = new URL(candidate); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return DEFAULT_APP_AI_SETTINGS.ollama.url; + } + + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + + const basePath = parsed.pathname && parsed.pathname !== '/' + ? parsed.pathname.replace(/\/+$/, '') + : ''; + return `${parsed.origin}${basePath}`; + } catch { + return DEFAULT_APP_AI_SETTINGS.ollama.url; + } +} + +function normalizeAppOllamaModel(value) { + const candidate = String(value || '').trim(); + if (!candidate || candidate.length > 120 || /[\s\x00-\x1F]/.test(candidate)) { + return DEFAULT_APP_AI_SETTINGS.ollama.model; + } + + return candidate; +} + +function normalizeAppAiTaskCount(value) { + const parsedCount = Number.parseInt(String(value || ''), 10); + if (Number.isInteger(parsedCount) && parsedCount >= 3 && parsedCount <= 12) { + return parsedCount; + } + + return DEFAULT_APP_AI_SETTINGS.ollama.taskCount; +} + +function cloneDefaultAppSmartCardActions() { + return DEFAULT_APP_SMART_CARD_ACTIONS.map((action) => ({ ...action })); +} + +function normalizeAppSmartCardActionLabel(value, fallback = '') { + const candidate = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + const normalizedFallback = String(fallback || '').trim(); + if (!candidate) { + return normalizedFallback; + } + + return candidate.slice(0, APP_SMART_CARD_ACTION_LABEL_MAX_LENGTH).trim() || normalizedFallback; +} + +function normalizeAppSmartCardActionPrompt(value, fallback = '') { + const candidate = String(value || '') + .replace(/\r\n?/g, '\n') + .trim(); + const normalizedFallback = String(fallback || '').trim(); + if (!candidate) { + return normalizedFallback; + } + + return candidate.slice(0, APP_SMART_CARD_ACTION_PROMPT_MAX_LENGTH).trim() || normalizedFallback; +} + +function normalizeAppSmartCardActionId(value, fallback = '') { + const candidate = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + const normalizedFallback = String(fallback || '').trim(); + return candidate || normalizedFallback; +} + +function normalizeAppSmartCardActions(actions) { + const sourceActions = Array.isArray(actions) ? actions : []; + const normalizedActions = []; + const seenIds = new Set(); + + for (const defaultAction of DEFAULT_APP_SMART_CARD_ACTIONS) { + const sourceAction = sourceActions.find((action) => ( + action && typeof action === 'object' && !Array.isArray(action) && String(action.id || '') === defaultAction.id + )); + normalizedActions.push({ + ...defaultAction, + label: defaultAction.label, + prompt: normalizeAppSmartCardActionPrompt( + sourceAction && Object.prototype.hasOwnProperty.call(sourceAction, 'prompt') + ? sourceAction.prompt + : defaultAction.prompt, + defaultAction.prompt, + ), + builtIn: true, + }); + seenIds.add(defaultAction.id); + } + + let customCount = 0; + for (const action of sourceActions) { + if (!action || typeof action !== 'object' || Array.isArray(action) || action.builtIn === true) { + continue; + } + + const label = normalizeAppSmartCardActionLabel(action.label); + const prompt = normalizeAppSmartCardActionPrompt(action.prompt); + if (!label || !prompt) { + continue; + } + + const fallbackId = `custom-${customCount + 1}`; + let id = normalizeAppSmartCardActionId(action.id, fallbackId); + if (DEFAULT_APP_SMART_CARD_ACTIONS.some((defaultAction) => defaultAction.id === id)) { + id = fallbackId; + } + while (seenIds.has(id)) { + id = `custom-${customCount + 1}-${seenIds.size + 1}`; + } + + normalizedActions.push({ + id, + type: 'custom', + label, + prompt, + builtIn: false, + }); + seenIds.add(id); + customCount += 1; + if (customCount >= APP_CUSTOM_SMART_CARD_ACTION_LIMIT) { + break; + } + } + + return normalizedActions; +} + +function normalizeAppOllamaSettings(ollamaSettings) { + const source = ollamaSettings && typeof ollamaSettings === 'object' && !Array.isArray(ollamaSettings) + ? ollamaSettings + : {}; + + return { + url: normalizeAppOllamaUrl(source.url), + model: normalizeAppOllamaModel(source.model), + taskCount: normalizeAppAiTaskCount(source.taskCount), + }; +} + +function normalizeAppAiSettings(aiSettings) { + const source = aiSettings && typeof aiSettings === 'object' && !Array.isArray(aiSettings) + ? aiSettings + : {}; + + return { + enabled: source.enabled === true, + provider: normalizeAppAiProvider(source.provider), + ollama: normalizeAppOllamaSettings(source.ollama), + smartCardActions: normalizeAppSmartCardActions(source.smartCardActions || source.cardActions), + }; +} + function normalizeAppGlobalShortcutStatus(status) { const source = status && typeof status === 'object' && !Array.isArray(status) ? status @@ -143,6 +385,63 @@ function normalizeAppExternalPublishedCalendarStatus(status) { }; } +function normalizeAppOllamaModelEntry(model) { + const source = model && typeof model === 'object' && !Array.isArray(model) + ? model + : {}; + const name = String(source.name || source.model || '').trim(); + if (!name) { + return null; + } + + return { + name, + model: String(source.model || name).trim() || name, + modifiedAt: typeof source.modifiedAt === 'string' + ? source.modifiedAt + : (typeof source.modified_at === 'string' ? source.modified_at : ''), + size: Number.isFinite(source.size) ? source.size : 0, + digest: typeof source.digest === 'string' ? source.digest : '', + details: source.details && typeof source.details === 'object' && !Array.isArray(source.details) + ? { ...source.details } + : {}, + }; +} + +function normalizeAppOllamaModels(models) { + const sourceModels = Array.isArray(models) ? models : []; + const seen = new Set(); + const normalizedModels = []; + + for (const model of sourceModels) { + const normalized = normalizeAppOllamaModelEntry(model); + if (!normalized || seen.has(normalized.name)) { + continue; + } + seen.add(normalized.name); + normalizedModels.push(normalized); + } + + return normalizedModels.sort((left, right) => left.name.localeCompare(right.name)); +} + +function normalizeAppOllamaModelStatus(status) { + const source = status && typeof status === 'object' && !Array.isArray(status) + ? status + : {}; + + return { + checked: source.checked === true, + checking: source.checking === true, + ok: source.ok === true, + url: typeof source.url === 'string' ? source.url.trim() : '', + models: normalizeAppOllamaModels(source.models), + message: typeof source.message === 'string' && source.message.trim() + ? source.message.trim() + : DEFAULT_APP_OLLAMA_MODEL_STATUS.message, + }; +} + function getAppNotificationSettings() { return normalizeAppNotificationSettings(getAppSettingsState().notificationSettings); } @@ -183,6 +482,15 @@ function setAppExternalPublishedCalendarSettings(calendarSettings) { state.externalPublishedCalendarSettings = normalizeAppExternalPublishedCalendarSettings(calendarSettings); } +function getAppAiSettings() { + return normalizeAppAiSettings(getAppSettingsState().aiSettings); +} + +function setAppAiSettings(aiSettings) { + const state = getAppSettingsState(); + state.aiSettings = normalizeAppAiSettings(aiSettings); +} + function getAppGlobalShortcutStatus() { return normalizeAppGlobalShortcutStatus(getAppSettingsState().globalShortcutStatus); } @@ -201,12 +509,30 @@ function setAppExternalPublishedCalendarStatus(status) { state.externalPublishedCalendarStatus = normalizeAppExternalPublishedCalendarStatus(status); } +function getAppOllamaModelStatus() { + return normalizeAppOllamaModelStatus(getAppSettingsState().ollamaModelStatus); +} + +function setAppOllamaModelStatus(status) { + const state = getAppSettingsState(); + state.ollamaModelStatus = normalizeAppOllamaModelStatus(status); +} + +function resetAppOllamaModelStatus(message = DEFAULT_APP_OLLAMA_MODEL_STATUS.message) { + setAppOllamaModelStatus({ + ...DEFAULT_APP_OLLAMA_MODEL_STATUS, + models: [], + message, + }); +} + function applyAppSettings(settings) { const source = settings && typeof settings === 'object' ? settings : {}; setAppNotificationSettings(source.notifications || DEFAULT_APP_NOTIFICATION_SETTINGS); setAppTooltipsEnabled(source.tooltipsEnabled); setAppQuickAddSettings(source.quickAdd || DEFAULT_APP_QUICK_ADD_SETTINGS); setAppExternalPublishedCalendarSettings(source.externalPublishedCalendar || DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS); + setAppAiSettings(source.ai || DEFAULT_APP_AI_SETTINGS); setAppGlobalShortcutStatus(source.globalShortcutStatus); setAppExternalPublishedCalendarStatus(source.externalPublishedCalendarStatus); getAppSettingsState().settingsLoaded = true; @@ -220,6 +546,7 @@ async function loadAppSettings() { tooltipsEnabled: getAppTooltipsEnabled(), quickAdd: getAppQuickAddSettings(), externalPublishedCalendar: getAppExternalPublishedCalendarSettings(), + ai: getAppAiSettings(), globalShortcutStatus: getAppGlobalShortcutStatus(), externalPublishedCalendarStatus: getAppExternalPublishedCalendarStatus(), }; @@ -228,6 +555,9 @@ async function loadAppSettings() { const settings = await window.electronAPI.readAppSettings(); applyAppSettings(settings); renderAppSettingsControls(); + if (getAppAiSettings().enabled) { + refreshAppAiOllamaModels(); + } return settings; } @@ -259,6 +589,9 @@ async function migrateAppSettingsFromOpenBoards() { if (migratedSettings) { applyAppSettings(migratedSettings); renderAppSettingsControls(); + if (getAppAiSettings().enabled) { + refreshAppAiOllamaModels(); + } return; } } catch (error) { @@ -283,11 +616,22 @@ function renderAppSettingsControls() { const externalCalendarUrlInput = document.getElementById('boardSettingsExternalCalendarUrl'); const externalCalendarCopyButton = document.getElementById('btnCopyExternalCalendarUrl'); const externalCalendarStatus = document.getElementById('boardSettingsExternalCalendarStatus'); + const aiToggle = document.getElementById('boardSettingsAiToggle'); + const aiDetails = document.getElementById('boardSettingsAiDetails'); + const aiOllamaUrlInput = document.getElementById('boardSettingsAiOllamaUrl'); + const aiOllamaModelSelect = document.getElementById('boardSettingsAiOllamaModel'); + const aiOllamaRefreshButton = document.getElementById('btnRefreshAiOllamaModels'); + const aiOllamaStatus = document.getElementById('boardSettingsAiOllamaStatus'); + const aiTaskCountInput = document.getElementById('boardSettingsAiTaskCount'); + const aiActionsList = document.getElementById('boardSettingsAiActionsList'); + const aiAddActionButton = document.getElementById('btnAddAiSmartCardAction'); const notifications = getAppNotificationSettings(); const quickAdd = getAppQuickAddSettings(); const externalCalendar = getAppExternalPublishedCalendarSettings(); const externalCalendarRuntime = getAppExternalPublishedCalendarStatus(); const globalShortcutStatus = getAppGlobalShortcutStatus(); + const aiSettings = getAppAiSettings(); + const ollamaModelStatus = getAppOllamaModelStatus(); if (tooltipsToggle) { tooltipsToggle.checked = getAppTooltipsEnabled(); @@ -351,6 +695,58 @@ function renderAppSettingsControls() { } } + if (aiToggle) { + aiToggle.checked = aiSettings.enabled; + } + + if (aiDetails) { + aiDetails.classList.toggle('hidden', !aiSettings.enabled); + aiDetails.setAttribute('aria-hidden', aiSettings.enabled ? 'false' : 'true'); + } + + if (aiOllamaUrlInput) { + aiOllamaUrlInput.value = aiSettings.ollama.url; + } + + if (aiOllamaModelSelect) { + renderAppOllamaModelSelect(aiOllamaModelSelect, aiSettings, ollamaModelStatus); + } + + if (aiOllamaRefreshButton) { + aiOllamaRefreshButton.disabled = !aiSettings.enabled || ollamaModelStatus.checking; + } + + if (aiOllamaStatus) { + aiOllamaStatus.classList.remove('is-success', 'is-warning'); + if (!aiSettings.enabled) { + aiOllamaStatus.textContent = 'Disabled'; + } else if (ollamaModelStatus.checking) { + aiOllamaStatus.textContent = 'Checking...'; + } else if (ollamaModelStatus.checked && ollamaModelStatus.ok) { + aiOllamaStatus.textContent = ollamaModelStatus.models.length > 0 + ? ollamaModelStatus.message + : 'Connected. No models found.'; + aiOllamaStatus.classList.add(ollamaModelStatus.models.length > 0 ? 'is-success' : 'is-warning'); + } else if (ollamaModelStatus.checked) { + aiOllamaStatus.textContent = ollamaModelStatus.message || 'Not running'; + aiOllamaStatus.classList.add('is-warning'); + } else { + aiOllamaStatus.textContent = 'Not checked'; + } + } + + if (aiTaskCountInput) { + aiTaskCountInput.value = String(aiSettings.ollama.taskCount); + } + + if (aiActionsList) { + renderAppSmartCardActionSettings(aiActionsList, aiSettings.smartCardActions); + } + + if (aiAddActionButton) { + aiAddActionButton.disabled = !aiSettings.enabled; + } + if (quickAddShortcutStatus) { const shortcut = quickAdd.globalShortcut; quickAddShortcutStatus.classList.remove('is-success', 'is-warning'); @@ -367,6 +763,303 @@ function renderAppSettingsControls() { quickAddShortcutStatus.textContent = 'Saved'; } } + + if (typeof renderCardEditorSmartActionControls === 'function') { + renderCardEditorSmartActionControls(); + } +} + +function renderAppSmartCardActionSettings(container, actions) { + if (!container) { + return; + } + + const normalizedActions = normalizeAppSmartCardActions(actions); + container.innerHTML = ''; + + for (const action of normalizedActions) { + const actionEl = document.createElement('div'); + actionEl.className = 'board-settings-ai-action'; + actionEl.dataset.actionId = action.id; + + const header = document.createElement('div'); + header.className = 'board-settings-ai-action-header'; + + if (action.builtIn) { + const title = document.createElement('p'); + title.className = 'board-settings-ai-action-title'; + title.textContent = action.label; + header.appendChild(title); + + const resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = 'board-settings-ai-action-reset'; + resetButton.dataset.smartActionCommand = 'reset'; + resetButton.dataset.actionId = action.id; + resetButton.textContent = 'Reset'; + header.appendChild(resetButton); + } else { + const label = document.createElement('label'); + label.className = 'board-settings-ai-action-label'; + label.setAttribute('for', `boardSettingsAiActionLabel-${action.id}`); + label.textContent = 'Custom action label'; + header.appendChild(label); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'board-settings-ai-action-remove'; + removeButton.dataset.smartActionCommand = 'remove'; + removeButton.dataset.actionId = action.id; + removeButton.textContent = 'Remove'; + header.appendChild(removeButton); + } + actionEl.appendChild(header); + + if (!action.builtIn) { + const labelInput = document.createElement('input'); + labelInput.id = `boardSettingsAiActionLabel-${action.id}`; + labelInput.type = 'text'; + labelInput.value = action.label; + labelInput.maxLength = APP_SMART_CARD_ACTION_LABEL_MAX_LENGTH; + labelInput.dataset.smartActionField = 'label'; + labelInput.dataset.actionId = action.id; + labelInput.placeholder = 'Action label'; + actionEl.appendChild(labelInput); + } + + const promptLabel = document.createElement('label'); + promptLabel.className = 'board-settings-ai-action-label'; + promptLabel.setAttribute('for', `boardSettingsAiActionPrompt-${action.id}`); + promptLabel.textContent = action.builtIn ? 'Prompt' : 'Custom action prompt'; + actionEl.appendChild(promptLabel); + + const promptTextarea = document.createElement('textarea'); + promptTextarea.id = `boardSettingsAiActionPrompt-${action.id}`; + promptTextarea.value = action.prompt; + promptTextarea.rows = action.builtIn ? 4 : 5; + promptTextarea.maxLength = APP_SMART_CARD_ACTION_PROMPT_MAX_LENGTH; + promptTextarea.dataset.smartActionField = 'prompt'; + promptTextarea.dataset.actionId = action.id; + promptTextarea.spellcheck = true; + actionEl.appendChild(promptTextarea); + + container.appendChild(actionEl); + } +} + +function getDefaultAppSmartCardAction(actionId) { + return DEFAULT_APP_SMART_CARD_ACTIONS.find((action) => action.id === actionId) || null; +} + +function updateAppSmartCardAction(actionId, partialAction = {}) { + const settings = getAppAiSettings(); + const nextActions = settings.smartCardActions.map((action) => { + if (action.id !== actionId) { + return action; + } + + return { + ...action, + ...partialAction, + }; + }); + + setAppAiSettings({ + ...settings, + smartCardActions: nextActions, + }); + renderAppSettingsControls(); + scheduleAppSettingsSave(); +} + +function resetAppSmartCardActionPrompt(actionId) { + const defaultAction = getDefaultAppSmartCardAction(actionId); + if (!defaultAction) { + return; + } + + updateAppSmartCardAction(actionId, { + prompt: defaultAction.prompt, + }); +} + +function removeAppSmartCardAction(actionId) { + const settings = getAppAiSettings(); + setAppAiSettings({ + ...settings, + smartCardActions: settings.smartCardActions.filter((action) => action.id !== actionId || action.builtIn), + }); + renderAppSettingsControls(); + scheduleAppSettingsSave(); +} + +function createAppSmartCardActionId() { + return `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} + +function addAppSmartCardAction() { + const settings = getAppAiSettings(); + const customActionCount = settings.smartCardActions.filter((action) => !action.builtIn).length; + if (customActionCount >= APP_CUSTOM_SMART_CARD_ACTION_LIMIT) { + return; + } + + setAppAiSettings({ + ...settings, + smartCardActions: [ + ...settings.smartCardActions, + { + id: createAppSmartCardActionId(), + type: 'custom', + label: `Custom action ${customActionCount + 1}`, + prompt: 'Use the card context to create useful Markdown to append to this card.', + builtIn: false, + }, + ], + }); + renderAppSettingsControls(); + scheduleAppSettingsSave(); +} + +function getAppOllamaModelOptionLabel(model) { + const source = model && typeof model === 'object' && !Array.isArray(model) + ? model + : {}; + const name = String(source.name || source.model || '').trim(); + if (!name) { + return ''; + } + + const details = source.details && typeof source.details === 'object' && !Array.isArray(source.details) + ? source.details + : {}; + const parameterSize = typeof details.parameter_size === 'string' ? details.parameter_size.trim() : ''; + return parameterSize ? `${name} (${parameterSize})` : name; +} + +function renderAppOllamaModelSelect(select, aiSettings, ollamaModelStatus) { + if (!select) { + return; + } + + const settings = normalizeAppAiSettings(aiSettings); + const status = normalizeAppOllamaModelStatus(ollamaModelStatus); + const selectedModel = settings.ollama.model; + const models = status.ok ? status.models : []; + const seen = new Set(); + + select.innerHTML = ''; + + const appendOption = (value, label, options = {}) => { + const normalizedValue = String(value || '').trim(); + const optionKey = normalizedValue || '__empty'; + if ((!normalizedValue && !options.allowEmpty) || seen.has(optionKey)) { + return; + } + + const option = document.createElement('option'); + option.value = normalizedValue; + option.textContent = label || normalizedValue; + if (options.disabled) { + option.disabled = true; + } + select.appendChild(option); + seen.add(optionKey); + }; + + if (selectedModel && !models.some((model) => model.name === selectedModel || model.model === selectedModel)) { + appendOption(selectedModel, status.checked && status.ok + ? `${selectedModel} (saved, not listed)` + : selectedModel); + } + + for (const model of models) { + appendOption(model.name, getAppOllamaModelOptionLabel(model)); + } + + if (seen.size === 0) { + appendOption('', status.checking ? 'Checking models...' : 'No models found', { + allowEmpty: true, + disabled: true, + }); + } + + select.value = seen.has(selectedModel) + ? selectedModel + : (select.options.length > 0 ? select.options[0].value : ''); + select.disabled = !settings.enabled || status.checking || select.options.length === 0; +} + +async function refreshAppAiOllamaModels() { + const state = getAppSettingsState(); + const settings = getAppAiSettings(); + + if (!settings.enabled) { + resetAppOllamaModelStatus('Disabled'); + renderAppSettingsControls(); + return; + } + + if (!window.electronAPI || typeof window.electronAPI.inspectOllama !== 'function') { + setAppOllamaModelStatus({ + checked: true, + checking: false, + ok: false, + url: settings.ollama.url, + models: [], + message: 'Ollama inspection is unavailable.', + }); + renderAppSettingsControls(); + return; + } + + const requestId = state.ollamaModelStatusRequestId + 1; + state.ollamaModelStatusRequestId = requestId; + setAppOllamaModelStatus({ + checked: false, + checking: true, + ok: false, + url: settings.ollama.url, + models: getAppOllamaModelStatus().models, + message: 'Checking...', + }); + renderAppSettingsControls(); + + let result = null; + try { + result = await window.electronAPI.inspectOllama({ + url: settings.ollama.url, + }); + } catch (error) { + console.error('Unable to inspect Ollama.', error); + } + + if (state.ollamaModelStatusRequestId !== requestId) { + return; + } + + if (!result || result.ok !== true) { + setAppOllamaModelStatus({ + checked: true, + checking: false, + ok: false, + url: settings.ollama.url, + models: [], + message: result && result.message ? result.message : 'Unable to reach Ollama.', + }); + renderAppSettingsControls(); + return; + } + + setAppOllamaModelStatus({ + checked: true, + checking: false, + ok: true, + url: result.url || settings.ollama.url, + models: result.models, + message: result.message || 'Connected.', + }); + renderAppSettingsControls(); } function scheduleAppSettingsSave() { @@ -395,6 +1088,7 @@ function persistAppSettings() { tooltipsEnabled: getAppTooltipsEnabled(), quickAdd: getAppQuickAddSettings(), externalPublishedCalendar: getAppExternalPublishedCalendarSettings(), + ai: getAppAiSettings(), }); applyAppSettings(result); renderAppSettingsControls(); diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index 043d21e..239f9e3 100644 --- a/app/board/boardLabels.js +++ b/app/board/boardLabels.js @@ -2653,7 +2653,7 @@ function renderBoardSettingsPanelState() { } function setActiveBoardSettingsPanel(panelId) { - const normalizedPanelId = ['app', 'general', 'obsidian', 'workflow', 'labels', 'colors', 'import'].includes(panelId) + const normalizedPanelId = ['app', 'notifications', 'smart-actions', 'general', 'labels', 'colors', 'workflow', 'obsidian', 'import'].includes(panelId) ? panelId : 'app'; const state = getBoardLabelState(); @@ -2739,6 +2739,23 @@ function handleBoardSettingsNavKeydown(event) { } } +function getBoardSettingsNavButtonId(panelId) { + const normalizedPanelId = String(panelId || ''); + const navButtonIds = { + app: 'boardSettingsNavApp', + notifications: 'boardSettingsNavNotifications', + 'smart-actions': 'boardSettingsNavSmartActions', + general: 'boardSettingsNavGeneral', + labels: 'boardSettingsNavLabels', + colors: 'boardSettingsNavColors', + workflow: 'boardSettingsNavWorkflow', + obsidian: 'boardSettingsNavObsidian', + import: 'boardSettingsNavImport', + }; + + return navButtonIds[normalizedPanelId] || navButtonIds.app; +} + function renderBoardGeneralSettingsControls() { const boardNameInput = document.getElementById('boardSettingsBoardNameInput'); const boardPathInput = document.getElementById('boardSettingsBoardPathInput'); @@ -3236,7 +3253,7 @@ function openBoardSettingsModal(options = {}) { : String(options.panel || 'app'); const initialFocus = options && typeof options === 'object' && options.initialFocus ? options.initialFocus - : (panel === 'labels' ? '#boardSettingsNavLabels' : '#boardSettingsNavApp'); + : (panel === 'labels' ? '#boardSettingsNavLabels' : `#${getBoardSettingsNavButtonId(panel)}`); closeBoardLabelFilterPopover(); closeCardLabelPopover(); @@ -3378,6 +3395,13 @@ function initializeBoardLabelControls() { const notificationsTimeInput = document.getElementById('boardSettingsNotificationsTime'); const tooltipsToggle = document.getElementById('boardSettingsTooltipsToggle'); const quickAddShortcutInput = document.getElementById('boardSettingsQuickAddShortcut'); + const aiToggle = document.getElementById('boardSettingsAiToggle'); + const aiOllamaUrlInput = document.getElementById('boardSettingsAiOllamaUrl'); + const aiOllamaModelSelect = document.getElementById('boardSettingsAiOllamaModel'); + const aiOllamaRefreshButton = document.getElementById('btnRefreshAiOllamaModels'); + const aiTaskCountInput = document.getElementById('boardSettingsAiTaskCount'); + const aiActionsList = document.getElementById('boardSettingsAiActionsList'); + const aiAddActionButton = document.getElementById('btnAddAiSmartCardAction'); const externalCalendarToggle = document.getElementById('boardSettingsExternalCalendarToggle'); const externalCalendarPortInput = document.getElementById('boardSettingsExternalCalendarPort'); const externalCalendarCopyButton = document.getElementById('btnCopyExternalCalendarUrl'); @@ -3636,6 +3660,171 @@ function initializeBoardLabelControls() { }); } + if (aiToggle) { + aiToggle.addEventListener('change', (event) => { + if (typeof getAppAiSettings !== 'function' || typeof setAppAiSettings !== 'function') { + return; + } + + const currentSettings = getAppAiSettings(); + setAppAiSettings({ + ...currentSettings, + enabled: Boolean(event.target.checked), + }); + renderAppSettingsControls(); + scheduleAppSettingsSave(); + if (event.target.checked && typeof refreshAppAiOllamaModels === 'function') { + refreshAppAiOllamaModels(); + } else if (!event.target.checked && typeof resetAppOllamaModelStatus === 'function') { + resetAppOllamaModelStatus('Disabled'); + renderAppSettingsControls(); + } + }); + } + + if (aiOllamaUrlInput) { + aiOllamaUrlInput.addEventListener('change', (event) => { + if (typeof getAppAiSettings !== 'function' || typeof setAppAiSettings !== 'function') { + return; + } + + const currentSettings = getAppAiSettings(); + setAppAiSettings({ + ...currentSettings, + ollama: { + ...currentSettings.ollama, + url: event.target.value, + }, + }); + if (typeof resetAppOllamaModelStatus === 'function') { + resetAppOllamaModelStatus('Not checked'); + } + renderAppSettingsControls(); + scheduleAppSettingsSave(); + if (getAppAiSettings().enabled && typeof refreshAppAiOllamaModels === 'function') { + refreshAppAiOllamaModels(); + } + }); + + aiOllamaUrlInput.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + aiOllamaUrlInput.blur(); + }); + } + + if (aiOllamaModelSelect) { + aiOllamaModelSelect.addEventListener('change', (event) => { + if (typeof getAppAiSettings !== 'function' || typeof setAppAiSettings !== 'function') { + return; + } + + const currentSettings = getAppAiSettings(); + setAppAiSettings({ + ...currentSettings, + ollama: { + ...currentSettings.ollama, + model: event.target.value, + }, + }); + renderAppSettingsControls(); + scheduleAppSettingsSave(); + }); + + aiOllamaModelSelect.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + aiOllamaModelSelect.blur(); + }); + } + + if (aiOllamaRefreshButton) { + aiOllamaRefreshButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof refreshAppAiOllamaModels === 'function') { + refreshAppAiOllamaModels(); + } + }); + } + + if (aiTaskCountInput) { + aiTaskCountInput.addEventListener('change', (event) => { + if (typeof getAppAiSettings !== 'function' || typeof setAppAiSettings !== 'function') { + return; + } + + const currentSettings = getAppAiSettings(); + setAppAiSettings({ + ...currentSettings, + ollama: { + ...currentSettings.ollama, + taskCount: event.target.value, + }, + }); + renderAppSettingsControls(); + scheduleAppSettingsSave(); + }); + + aiTaskCountInput.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + aiTaskCountInput.blur(); + }); + } + + if (aiActionsList) { + aiActionsList.addEventListener('change', (event) => { + const target = event.target; + if (!target || !target.dataset || !target.dataset.smartActionField || !target.dataset.actionId) { + return; + } + + if (typeof updateAppSmartCardAction !== 'function') { + return; + } + + updateAppSmartCardAction(target.dataset.actionId, { + [target.dataset.smartActionField]: target.value, + }); + }); + + aiActionsList.addEventListener('click', (event) => { + const target = event.target; + if (!target || !target.dataset || !target.dataset.smartActionCommand || !target.dataset.actionId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (target.dataset.smartActionCommand === 'reset' && typeof resetAppSmartCardActionPrompt === 'function') { + resetAppSmartCardActionPrompt(target.dataset.actionId); + } else if (target.dataset.smartActionCommand === 'remove' && typeof removeAppSmartCardAction === 'function') { + removeAppSmartCardAction(target.dataset.actionId); + } + }); + } + + if (aiAddActionButton) { + aiAddActionButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof addAppSmartCardAction === 'function') { + addAppSmartCardAction(); + } + }); + } + if (externalCalendarToggle) { externalCalendarToggle.addEventListener('change', (event) => { if (typeof setAppExternalPublishedCalendarSettings !== 'function') { diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index 64bd782..0f1f9fb 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -106,7 +106,9 @@ function getStoredOpenBoards() { } function setStoredOpenBoards(openBoards) { - localStorage.setItem(OPEN_BOARDS_STORAGE_KEY, JSON.stringify(sanitizeOpenBoards(openBoards))); + const sanitizedOpenBoards = sanitizeOpenBoards(openBoards); + localStorage.setItem(OPEN_BOARDS_STORAGE_KEY, JSON.stringify(sanitizedOpenBoards)); + syncOpenBoardsStateSnapshot(); } function getStoredActiveBoard() { @@ -117,6 +119,20 @@ function setStoredActiveBoard(boardPath) { const normalizedPath = normalizeBoardPath(boardPath); localStorage.setItem(ACTIVE_BOARD_STORAGE_KEY, normalizedPath); localStorage.setItem('boardPath', normalizedPath); + syncOpenBoardsStateSnapshot(); +} + +function syncOpenBoardsStateSnapshot() { + if (!window.board || typeof window.board.syncOpenBoardsState !== 'function') { + return; + } + + window.board.syncOpenBoardsState({ + openBoardPaths: getStoredOpenBoards(), + activeBoardPath: getStoredActiveBoard(), + }).catch((error) => { + console.warn('Unable to sync Signboard open board state.', error); + }); } function ensureBoardInTabs(boardPath) { diff --git a/app/init.js b/app/init.js index 03c217d..c5946b5 100644 --- a/app/init.js +++ b/app/init.js @@ -1103,6 +1103,9 @@ async function init() { if (typeof closeCardEditorLinkedObjectsPopoverIfClickOutside === 'function') { closeCardEditorLinkedObjectsPopoverIfClickOutside(e.target); } + if (typeof closeCardEditorSmartActionsPopoverIfClickOutside === 'function') { + closeCardEditorSmartActionsPopoverIfClickOutside(e.target); + } await closeAllModals(e); }); diff --git a/app/modals/closeAllModals.js b/app/modals/closeAllModals.js index 9098bf2..2100a23 100644 --- a/app/modals/closeAllModals.js +++ b/app/modals/closeAllModals.js @@ -72,6 +72,10 @@ function isCardEditorRelatedClickTarget(target) { return true; } + if (target.closest('#cardEditorSmartActionsPopover')) { + return true; + } + if (target.closest('.sb-themed-fdatepicker')) { return true; } @@ -162,6 +166,9 @@ async function closeAllModals(e, options = {}){ if (closeAllRequest && typeof closeCardEditorLinkedObjectsPopover === 'function') { closeCardEditorLinkedObjectsPopover(); } + if (closeAllRequest && typeof closeCardEditorSmartActionsPopover === 'function') { + closeCardEditorSmartActionsPopover(); + } if ( closeAllRequest ) { if ( isVisibleModal(modalAddCard) ) { diff --git a/app/modals/toggleEditCardModal.js b/app/modals/toggleEditCardModal.js index d91a5c1..902ff8c 100644 --- a/app/modals/toggleEditCardModal.js +++ b/app/modals/toggleEditCardModal.js @@ -175,6 +175,7 @@ let editorSaveOperationInFlight = false; let isApplyingExternalEditorRefresh = false; let cardEditorDropDepth = 0; let cardEditorBodyUrlControlsTeardown = null; +let cardEditorSmartActionsRequestId = 0; const CARD_EDITOR_BODY_URL_PATTERN = /(?:https?:\/\/|www\.)[^\s<>"'`]+/gi; function getActiveEditorCardPath() { @@ -209,6 +210,9 @@ function clearActiveCardEditorState() { if (typeof destroyCardEditorBodyUrlControls === 'function') { destroyCardEditorBodyUrlControls(); } + if (typeof closeCardEditorSmartActionsPopover === 'function') { + closeCardEditorSmartActionsPopover(); + } activeCardEditorInstance = null; activeEditorDiskState = null; editorSaveOperationInFlight = false; @@ -2646,6 +2650,13 @@ async function toggleEditCardModal(cardPath, options = {}) { cardEditorOpenWithLink.addEventListener('click', toggleCardEditorOpenWithPopover); } + const cardEditorSmartActionsButton = document.getElementById('cardEditorSmartActionsButton'); + if (cardEditorSmartActionsButton) { + cardEditorSmartActionsButton.removeEventListener('click', toggleCardEditorSmartActionsPopover); + cardEditorSmartActionsButton.addEventListener('click', toggleCardEditorSmartActionsPopover); + renderCardEditorSmartActionControls(); + } + const cardEditorLinkedObjectsLink = document.getElementById('cardEditorLinkedObjectsLink'); if (cardEditorLinkedObjectsLink) { cardEditorLinkedObjectsLink.removeEventListener('click', toggleCardEditorLinkedObjectsPopover); @@ -3200,6 +3211,40 @@ function closeCardEditorLinkedObjectsPopoverIfClickOutside(target) { closeCardEditorLinkedObjectsPopover(); } +function closeCardEditorSmartActionsPopover() { + const popover = document.getElementById('cardEditorSmartActionsPopover'); + const trigger = document.getElementById('cardEditorSmartActionsButton'); + if (!popover) { + return; + } + + cardEditorSmartActionsRequestId += 1; + popover.classList.add('hidden'); + popover.setAttribute('aria-hidden', 'true'); + popover.innerHTML = ''; + popover.onkeydown = null; + if (trigger) { + trigger.setAttribute('aria-expanded', 'false'); + } +} + +function closeCardEditorSmartActionsPopoverIfClickOutside(target) { + const popover = document.getElementById('cardEditorSmartActionsPopover'); + const trigger = document.getElementById('cardEditorSmartActionsButton'); + if (!popover || popover.classList.contains('hidden')) { + return; + } + + if ( + (popover.contains(target)) || + (trigger && trigger.contains(target)) + ) { + return; + } + + closeCardEditorSmartActionsPopover(); +} + function setCardEditorOpenWithPopoverPosition(trigger, popover) { if (!trigger || !popover || typeof trigger.getBoundingClientRect !== 'function') { return; @@ -3267,6 +3312,530 @@ function createCardEditorOpenWithAction({ label, icon, onClick, disabled = false return button; } +function getCardEditorAiSettings() { + if (typeof getAppAiSettings === 'function') { + return getAppAiSettings(); + } + + return { + enabled: false, + provider: 'ollama', + ollama: { + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 6, + }, + smartCardActions: [], + }; +} + +function getCardEditorSmartActions() { + const aiSettings = getCardEditorAiSettings(); + return Array.isArray(aiSettings.smartCardActions) + ? aiSettings.smartCardActions.filter((action) => action && action.prompt && action.label) + : []; +} + +function renderCardEditorSmartActionControls() { + const button = document.getElementById('cardEditorSmartActionsButton'); + if (!button) { + return; + } + + const aiSettings = getCardEditorAiSettings(); + const isAvailable = Boolean( + aiSettings.enabled && + window.electronAPI && + typeof window.electronAPI.runSmartCardAction === 'function' && + getCardEditorSmartActions().length > 0, + ); + + button.hidden = !isAvailable; + button.disabled = !isAvailable; + if (!isAvailable) { + closeCardEditorSmartActionsPopover(); + return; + } + + const model = aiSettings.ollama && aiSettings.ollama.model + ? aiSettings.ollama.model + : 'Ollama'; + button.title = `Smart Card Actions with ${model}`; + button.setAttribute('aria-label', `Smart Card Actions with ${model}`); +} + +function getActiveEditorAiLabelNames() { + const frontmatter = getEditorFrontmatter(); + const labelIds = Array.isArray(frontmatter.labels) ? frontmatter.labels : []; + return labelIds + .map((labelId) => (typeof getBoardLabelById === 'function' ? getBoardLabelById(labelId) : null)) + .map((label) => (label && label.name ? String(label.name).trim() : '')) + .filter(Boolean); +} + +function getActiveEditorAiContext() { + const cardPath = getActiveEditorCardPath(); + const listPath = getCardListPath(cardPath); + const listDirectoryName = getPathDirectoryName(listPath); + const boardName = window.board && typeof window.board.getBoardName === 'function' + ? window.board.getBoardName(window.boardRoot || '') + : ''; + + return { + title: getEditorTitleValue(), + body: getEditorBodyValue(), + boardName, + listName: getCardEditorListDisplayName(listDirectoryName), + labels: getActiveEditorAiLabelNames(), + }; +} + +function normalizeCardEditorSmartTasks(tasks) { + const sourceTasks = Array.isArray(tasks) ? tasks : []; + const seen = new Set(); + const normalizedTasks = []; + + for (const task of sourceTasks) { + const normalized = String(task || '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .replace(/^[-*+]\s+/, '') + .replace(/^\d+[.)]\s+/, '') + .replace(/^\[[ xX]\]\s+/, '') + .replace(/^- \[[ xX]\]\s+/, '') + .replace(/\s+/g, ' ') + .trim(); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + + seen.add(key); + normalizedTasks.push(normalized); + } + + return normalizedTasks; +} + +function buildCardEditorChecklistMarkdown(tasks) { + return normalizeCardEditorSmartTasks(tasks) + .map((task) => `- [ ] ${task}`) + .join('\n'); +} + +function appendMarkdownToCardEditorBody(body, markdown) { + const currentBody = String(body || '').replace(/\s+$/g, ''); + const normalizedMarkdown = String(markdown || '').trim(); + if (!normalizedMarkdown) { + return currentBody; + } + + return currentBody + ? `${currentBody}\n\n${normalizedMarkdown}\n` + : `${normalizedMarkdown}\n`; +} + +function setCardEditorBodyValue(nextBody) { + if (activeCardEditorInstance && typeof activeCardEditorInstance.setValue === 'function') { + activeCardEditorInstance.setValue(nextBody); + return; + } + + const editorTextarea = document.querySelector('#cardEditorOverType .overtype-input'); + if (editorTextarea) { + editorTextarea.value = nextBody; + editorTextarea.dispatchEvent(new Event('input', { bubbles: true })); + } +} + +async function appendCardEditorSmartMarkdown(markdown, successMessage) { + const normalizedMarkdown = String(markdown || '').trim(); + if (!normalizedMarkdown) { + return; + } + + await flushEditorSaveIfNeeded(); + const nextBody = appendMarkdownToCardEditorBody(getEditorBodyValue(), normalizedMarkdown); + setCardEditorBodyValue(nextBody); + pendingEditorBody = nextBody; + queueEditorSave(nextBody); + await flushEditorSaveIfNeeded(); + + if (successMessage && typeof announceSignboardStatus === 'function') { + announceSignboardStatus(successMessage); + } +} + +async function insertCardEditorSmartTasks(tasks) { + const normalizedTasks = normalizeCardEditorSmartTasks(tasks); + const checklistMarkdown = buildCardEditorChecklistMarkdown(normalizedTasks); + if (!checklistMarkdown) { + return; + } + + await appendCardEditorSmartMarkdown( + checklistMarkdown, + `Inserted ${normalizedTasks.length} suggested task${normalizedTasks.length === 1 ? '' : 's'}.`, + ); +} + +async function replaceCardEditorSmartTitle(title) { + const normalizedTitle = String(title || '').replace(/\s+/g, ' ').trim(); + const titleEl = document.getElementById('cardEditorTitle'); + if (!normalizedTitle || !titleEl) { + return; + } + + await flushEditorSaveIfNeeded(); + titleEl.textContent = normalizedTitle; + pendingEditorBody = getEditorBodyValue(); + queueEditorSave(pendingEditorBody); + await flushEditorSaveIfNeeded(); + if (typeof renderBoard === 'function') { + await renderBoard(); + } + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus('Updated card title.'); + } +} + +function createCardEditorSmartActionButton(label, className, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.textContent = label; + button.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + if (button.disabled) { + return; + } + + button.disabled = true; + try { + await onClick(); + } finally { + if (button.isConnected) { + button.disabled = false; + } + } + }); + return button; +} + +function renderCardEditorSmartActionsMenu(popover) { + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = 'Smart Card Actions'; + popover.appendChild(title); + + const menu = document.createElement('div'); + menu.className = 'card-editor-smart-actions-menu'; + for (const action of getCardEditorSmartActions()) { + const button = createCardEditorSmartMenuButton(action); + menu.appendChild(button); + } + popover.appendChild(menu); +} + +function getCardEditorSmartActionIcon(action) { + if (action.type === 'title') { + return 'type'; + } + if (action.type === 'tasks') { + return 'check-square'; + } + if (action.type === 'paste') { + return 'clipboard'; + } + return 'zap'; +} + +function createCardEditorSmartMenuButton(action) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'board-menu-action card-editor-smart-action-button'; + + const iconWrap = document.createElement('span'); + iconWrap.className = 'board-menu-action-icon'; + iconWrap.setAttribute('aria-hidden', 'true'); + const icon = getCardEditorSmartActionIcon(action); + if (window.feather && window.feather.icons && window.feather.icons[icon]) { + iconWrap.innerHTML = window.feather.icons[icon].toSvg(); + } + button.appendChild(iconWrap); + + const labelEl = document.createElement('span'); + labelEl.className = 'board-menu-action-label'; + labelEl.textContent = action.label; + button.appendChild(labelEl); + + button.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await startCardEditorSmartAction(action); + }); + + return button; +} + +function renderCardEditorSmartActionLoading(popover, action) { + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = action && action.label ? action.label : 'Smart Card Action'; + popover.appendChild(title); + + const status = document.createElement('p'); + status.className = 'card-editor-ai-tasks-status'; + status.textContent = 'Working...'; + popover.appendChild(status); +} + +function renderCardEditorSmartActionError(popover, message, action = null, options = {}) { + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = 'No result'; + popover.appendChild(title); + + const status = document.createElement('p'); + status.className = 'card-editor-ai-tasks-status is-error'; + status.textContent = String(message || 'Unable to run Smart Card Action.'); + popover.appendChild(status); + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + if (action) { + actions.appendChild(createCardEditorSmartActionButton('Try Again', 'card-editor-ai-tasks-secondary', async () => { + await requestCardEditorSmartAction(popover, action, options); + })); + } + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + actions.appendChild(createCardEditorSmartActionButton('Close', 'card-editor-ai-tasks-secondary', async () => { + closeCardEditorSmartActionsPopover(); + })); + popover.appendChild(actions); +} + +function renderCardEditorSmartTitleResult(popover, action, result, options = {}) { + const suggestedTitle = String(result && result.title ? result.title : '').trim(); + if (!suggestedTitle) { + renderCardEditorSmartActionError(popover, 'The model did not return a usable title.', action, options); + return; + } + + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = 'Suggested title'; + popover.appendChild(title); + + const preview = document.createElement('div'); + preview.className = 'card-editor-smart-action-preview'; + preview.textContent = suggestedTitle; + popover.appendChild(preview); + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + actions.appendChild(createCardEditorSmartActionButton('Replace', 'card-editor-ai-tasks-primary', async () => { + await replaceCardEditorSmartTitle(suggestedTitle); + closeCardEditorSmartActionsPopover(); + })); + actions.appendChild(createCardEditorSmartActionButton('Try Again', 'card-editor-ai-tasks-secondary', async () => { + await requestCardEditorSmartAction(popover, action, options); + })); + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + popover.appendChild(actions); +} + +function renderCardEditorSmartTasksResult(popover, action, result, options = {}) { + const normalizedTasks = normalizeCardEditorSmartTasks(result && result.tasks); + if (normalizedTasks.length === 0) { + renderCardEditorSmartActionError(popover, 'The model did not return any usable tasks.', action, options); + return; + } + + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = 'Suggested tasks'; + popover.appendChild(title); + + const list = document.createElement('ul'); + list.className = 'card-editor-ai-tasks-list'; + for (const task of normalizedTasks) { + const item = document.createElement('li'); + item.textContent = task; + list.appendChild(item); + } + popover.appendChild(list); + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + actions.appendChild(createCardEditorSmartActionButton('Insert', 'card-editor-ai-tasks-primary', async () => { + await insertCardEditorSmartTasks(normalizedTasks); + closeCardEditorSmartActionsPopover(); + })); + actions.appendChild(createCardEditorSmartActionButton('Try Again', 'card-editor-ai-tasks-secondary', async () => { + await requestCardEditorSmartAction(popover, action, options); + })); + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + popover.appendChild(actions); + + const insertButton = actions.querySelector('button'); + if (insertButton && typeof insertButton.focus === 'function') { + insertButton.focus(); + } +} + +function renderCardEditorSmartMarkdownResult(popover, action, result, options = {}) { + const markdown = String(result && result.body ? result.body : '').trim(); + if (!markdown) { + renderCardEditorSmartActionError(popover, 'The model did not return usable Markdown.', action, options); + return; + } + + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = action && action.label ? action.label : 'Suggested content'; + popover.appendChild(title); + + const preview = document.createElement('div'); + preview.className = 'card-editor-smart-action-preview'; + preview.textContent = markdown; + popover.appendChild(preview); + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + actions.appendChild(createCardEditorSmartActionButton('Insert', 'card-editor-ai-tasks-primary', async () => { + await appendCardEditorSmartMarkdown(markdown, 'Inserted suggested content.'); + closeCardEditorSmartActionsPopover(); + })); + actions.appendChild(createCardEditorSmartActionButton('Try Again', 'card-editor-ai-tasks-secondary', async () => { + await requestCardEditorSmartAction(popover, action, options); + })); + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + popover.appendChild(actions); +} + +function renderCardEditorSmartActionResult(popover, action, result, options = {}) { + const actionType = String(result && result.actionType ? result.actionType : action.type || ''); + if (actionType === 'title') { + renderCardEditorSmartTitleResult(popover, action, result, options); + } else if (actionType === 'tasks') { + renderCardEditorSmartTasksResult(popover, action, result, options); + } else { + renderCardEditorSmartMarkdownResult(popover, action, result, options); + } +} + +async function requestCardEditorSmartAction(popover, action, options = {}) { + if (!popover || !window.electronAPI || typeof window.electronAPI.runSmartCardAction !== 'function') { + return; + } + + const requestId = cardEditorSmartActionsRequestId + 1; + cardEditorSmartActionsRequestId = requestId; + renderCardEditorSmartActionLoading(popover, action); + + try { + const result = await window.electronAPI.runSmartCardAction({ + actionId: action.id, + context: getActiveEditorAiContext(), + pasteText: typeof options.pasteText === 'string' ? options.pasteText : '', + }); + if (requestId !== cardEditorSmartActionsRequestId || !popover.isConnected || popover.classList.contains('hidden')) { + return; + } + + if (!result || result.ok !== true) { + if (result && result.debug) { + console.error('Smart Card Action debug details:', result.debug); + } + renderCardEditorSmartActionError(popover, result && result.message ? result.message : 'Unable to run Smart Card Action.', action, options); + return; + } + + renderCardEditorSmartActionResult(popover, action, result, options); + } catch (error) { + if (requestId !== cardEditorSmartActionsRequestId || !popover.isConnected || popover.classList.contains('hidden')) { + return; + } + console.error('Unable to run Smart Card Action.', error); + renderCardEditorSmartActionError(popover, 'Unable to run Smart Card Action.', action, options); + } +} + +function renderCardEditorSmartPasteInput(popover, action) { + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = action.label; + popover.appendChild(title); + + const textarea = document.createElement('textarea'); + textarea.className = 'card-editor-smart-paste-input'; + textarea.placeholder = 'Paste text here'; + popover.appendChild(textarea); + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + actions.appendChild(createCardEditorSmartActionButton('Run', 'card-editor-ai-tasks-primary', async () => { + const pasteText = String(textarea.value || '').trim(); + if (!pasteText) { + textarea.focus(); + return; + } + await requestCardEditorSmartAction(popover, action, { pasteText }); + })); + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + actions.appendChild(createCardEditorSmartActionButton('Close', 'card-editor-ai-tasks-secondary', async () => { + closeCardEditorSmartActionsPopover(); + })); + popover.appendChild(actions); + + textarea.focus(); +} + +async function startCardEditorSmartAction(action) { + const popover = document.getElementById('cardEditorSmartActionsPopover'); + if (!popover || !action) { + return; + } + + if (action.type === 'paste') { + renderCardEditorSmartPasteInput(popover, action); + setCardEditorOpenWithPopoverPosition(document.getElementById('cardEditorSmartActionsButton'), popover); + return; + } + + await requestCardEditorSmartAction(popover, action); + setCardEditorOpenWithPopoverPosition(document.getElementById('cardEditorSmartActionsButton'), popover); +} + function appendCardEditorOpenWithSeparator(popover) { if (!popover) { return; @@ -3760,6 +4329,7 @@ async function toggleCardEditorOpenWithPopover(event) { } closeCardEditorLinkedObjectsPopover(); + closeCardEditorSmartActionsPopover(); if (popover.parentElement !== document.body) { document.body.appendChild(popover); @@ -3861,6 +4431,7 @@ async function toggleCardEditorLinkedObjectsPopover(event) { } closeCardEditorOpenWithPopover(); + closeCardEditorSmartActionsPopover(); if (popover.parentElement !== document.body) { document.body.appendChild(popover); @@ -3928,6 +4499,49 @@ async function toggleCardEditorLinkedObjectsPopover(event) { } } +async function toggleCardEditorSmartActionsPopover(event) { + event.preventDefault(); + event.stopPropagation(); + + const trigger = document.getElementById('cardEditorSmartActionsButton'); + const popover = document.getElementById('cardEditorSmartActionsPopover'); + if (!trigger || !popover || trigger.hidden || trigger.disabled) { + return; + } + + if (!popover.classList.contains('hidden')) { + closeCardEditorSmartActionsPopover(); + return; + } + + closeCardEditorOpenWithPopover(); + closeCardEditorLinkedObjectsPopover(); + + if (popover.parentElement !== document.body) { + document.body.appendChild(popover); + } + popover.setAttribute('data-sb-modal-layer', ''); + popover.inert = false; + popover.removeAttribute('data-sb-modal-inert'); + popover.tabIndex = -1; + popover.onkeydown = (keydownEvent) => { + if (typeof handleLabelPopoverKeyboard === 'function') { + handleLabelPopoverKeyboard(keydownEvent, { + popover, + close: closeCardEditorSmartActionsPopover, + restoreFocusElement: trigger, + }); + } + }; + + popover.classList.remove('hidden'); + popover.setAttribute('aria-hidden', 'false'); + trigger.setAttribute('aria-expanded', 'true'); + renderCardEditorSmartActionsMenu(popover); + setCardEditorOpenWithPopoverPosition(trigger, popover); + popover.focus(); +} + async function handleClickDuplicateCard( e ) { e.preventDefault(); e.stopPropagation(); diff --git a/docs/README.md b/docs/README.md index 5e42e32..a477130 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,15 +22,15 @@ If you want structured tool access from an agent, see [MCP Server](../MCP_README - Drag-and-drop movement, including the empty insertion slot shown while dragging cards - Start/due date ranges on Kanban cards, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting in Table, Planner date views, and External Published Calendar - Archiving and restoring cards and lists -- Settings, including app-wide tooltips/notifications/Quick Add global shortcut/External Published Calendar and board-specific workflow, colors, labels, and imports +- Settings, including app-wide Settings/Notifications/Smart Actions panels and board-specific General, Labels, Appearance, Workflow, Obsidian, and Import panels - Obsidian integration, including boards stored inside vaults, Open With actions, generated Bases files, linked notes, missing-note handling, linked objects, dropped local-file linking, URL favicons, `signboard://` card/board links, and the optional Obsidian companion plugin - Raw web URLs in card bodies, opened from the editor through the inline open-link control or Cmd/Ctrl-click - Native text editing context menus in editable fields - Accessibility support for keyboard-operable cards/list actions, modal focus handling, live status announcements, reduced motion, and forced-colors mode - Keyboard result/menu navigation for board search, Planner search, Archive search, board tabs, list actions, label/filter popovers, and Settings sections - Keyboard shortcuts for Quick Add card creation across open boards, creating lists, switching and closing open boards, opening Planner views across all open boards or the current board, cycling colors, moving open cards, archiving, and opening Archive -- CLI setup, board creation, command reference, filters, age sorting, timestamp JSON output, card duplication/template workflows, dry-run previews, archive workflows, settings, and imports -- MCP trusted-root behavior and board-name lookup +- CLI setup, board discovery, board creation, command reference, filters, age sorting, timestamp JSON output, card duplication/template workflows, dry-run previews, archive workflows, settings, and imports +- MCP trusted/open board discovery, trusted-root behavior, and board-name lookup ## File-First Model diff --git a/docs/codex/FILE_STRUCTURE.md b/docs/codex/FILE_STRUCTURE.md index f4f8645..7db4795 100644 --- a/docs/codex/FILE_STRUCTURE.md +++ b/docs/codex/FILE_STRUCTURE.md @@ -4,12 +4,12 @@ This map focuses on source and operational files. Large generated/vendor folders ## Top level -- `main.js` - Electron main process window + IPC handlers + trusted board-root/path validation + filesystem watchers + opt-in localhost External Published Calendar server + native menu/accelerators (including board switcher/settings/theme shortcuts plus required-action validation/rebuild on focus) + optional Quick Add global shortcut registration + renderer right-click text editing context menu with deferred native popup handling + archive browse/restore + top-of-list card move IPC + linked-object add/open/drop/status/recreate/relink IPC with URL favicon caching + Obsidian outbound/deep-link IPC and `signboard://` protocol dispatch for card and validated board opening + GitHub-release auto-update flow (`electron-updater`), including release-note formatting that strips a `## Downloads` section from in-app update dialogs. +- `main.js` - Electron main process window + IPC handlers + trusted board-root/path validation + last-known open-board state persistence for MCP/CLI discovery + filesystem watchers + opt-in localhost External Published Calendar server + native menu/accelerators (including board switcher/settings/theme shortcuts plus required-action validation/rebuild on focus) + optional Quick Add global shortcut registration + Smart Card Action/Ollama model-list IPC + renderer right-click text editing context menu with deferred native popup handling + archive browse/restore + top-of-list card move IPC + linked-object add/open/drop/status/recreate/relink IPC with URL favicon caching + Obsidian outbound/deep-link IPC and `signboard://` protocol dispatch for card and validated board opening + GitHub-release auto-update flow (`electron-updater`), including release-note formatting that strips a `## Downloads` section from in-app update dialogs. - `CODEX.md` - Canonical Codex-specific repo instructions and maintenance rules. - `AGENTS.md` - Cross-tool compatibility entrypoint that points agents to `CODEX.md`. - `DESIGN.md` - Design.md-compatible default theme tokens and visual rationale for Signboard's UI. - `MCP_README.md` - Dedicated setup guide for Signboard MCP server mode (`--mcp-server`). -- `preload.js` - Thin renderer bridge (`window.board`, `window.chooser`, `window.electronAPI`) that forwards allowed operations to main-process IPC and main-process-triggered renderer events, including board switcher/view/settings/Quick Add/signboard-card-link/signboard-board-link events, clipboard text copy, archive browse/read/restore, Obsidian actions, dropped-file path extraction for linked objects, and top-of-list card move calls. +- `preload.js` - Thin renderer bridge (`window.board`, `window.chooser`, `window.electronAPI`) that forwards allowed operations to main-process IPC and main-process-triggered renderer events, including board switcher/view/settings/Quick Add/signboard-card-link/signboard-board-link events, clipboard text copy, Smart Card Actions, Ollama model inspection, archive browse/read/restore, Obsidian actions, dropped-file path extraction for linked objects, and top-of-list card move calls. - `index.html` - App shell, header board tab strip, left-edge Planner rail/overlay markup, fixed Sponsor pill, board-menu view/archive/switcher modal markup (including `#boardViewButton`, `#modalKeyboardShortcuts`, `#modalBoardSwitcher`, `#modalArchiveBrowser`, and `#modalObsidianVaultRequired`), and deferred script/style includes. - `readme.md` - Human-facing project README. - `docs/release-template.md` - Curated GitHub release-body template for public download links. @@ -33,14 +33,14 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/utilities/cardDragTilt.js` - Shared card Sortable fallback options, drag tilt, reduced-motion handling, and drag text-selection lock used by Kanban and temporal card drag/drop. - `app/utilities/cardTimestamps.js` - Renderer card timestamp formatting helpers for editor metadata and Table age columns. - `app/utilities/linkedObjects.js` - Shared renderer helpers for counting structured `linked_objects` and legacy `related` links, plus paperclip count badge creation for Kanban/Table. -- `app/appSettings.js` - Renderer app-settings state, app-wide tooltip/notification/Quick Add global shortcut/External Published Calendar controls, persistence scheduling, and one-time migration from legacy board settings. -- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal board panels/nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. +- `app/appSettings.js` - Renderer app-settings state, app-wide Settings/Notifications/Smart Actions controls, tooltip/notification/Quick Add global shortcut/AI assistance/External Published Calendar persistence scheduling, and one-time migration from legacy board settings. +- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal app/current-board panel nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. - `app/board/boardSearch.js` - Board search state, input handling for title/body filtering, and keyboard navigation from the search field through visible card results. - `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card start/due and open task start/due markers, and source-list/source-board pills on temporal cards. - `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table sort controls, and list-column card moves through the top-of-list move IPC path. - `app/board/plannerView.js` - Workspace-level Planner overlay with Calendar, This Week, Day, and Agenda views across currently open boards, all/current/custom board scope controls, Planner-local search/date/completed-card/board/active-board-label filters, keyboard navigation for Planner search/filter controls, left-rail open/close behavior, and Planner card opening that switches the active board when needed. - `app/board/archiveBrowser.js` - Dedicated Archive modal UI, search-first archived card/list browsing with keyboard result navigation, detail-pane rendering, incremental result loading, and restore flows. -- `app/board/boardTabs.js` - Open-board tab session state (restore/add/close/reorder), keyboard navigation/close behavior for visible tabs, responsive `N more` overflow for unbounded open boards, plus the shared safe board-switch helper used by tab clicks and the switcher. +- `app/board/boardTabs.js` - Open-board tab session state (restore/add/close/reorder), last-known open/active board snapshot sync to main process, keyboard navigation/close behavior for visible tabs, responsive `N more` overflow for unbounded open boards, plus the shared safe board-switch helper used by tab clicks and the switcher. - `app/board/boardSwitcher.js` - Quick board switcher overlay for `Cmd/Ctrl + K`, filtering and closing currently open boards and delegating selected board changes to the shared switch helper. - `app/cards/createCardElement.js` - Card DOM rendering, compact start/due date metadata popover, task progress and linked-object badge display, list-item/card-title button semantics, and click behavior. - `app/cards/processAddNewCard.js` - New card creation flow, including open-board targeting and optional create-and-open behavior. @@ -53,7 +53,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/modals/toggleAddCardModal.js` - Add-card modal position/toggle. - `app/modals/toggleAddListModal.js` - Add-list modal position/toggle. - `app/modals/toggleAddCardToListModal.js` - Cross-list add-card modal toggle. -- `app/modals/toggleEditCardModal.js` - Card editor open/save/archive/duplicate logic, card start/due metadata controls, Created/Updated timestamp display, active-card top-of-list moves from the dropdown/directional controls, debounced + serialized saves, clean-editor reloads after external/MCP card edits, fresh duplicate lifecycle metadata, raw body URL detection/open controls, linked-object paperclip controls including inline URL/app-link entry, Obsidian rename reconciliation between `linked_objects` and `related` wikilinks, missing-note status rendering with recreate/relink/remove actions, drag/drop local-file linking, Open With/Obsidian actions, and task-line start/due date controls aligned from measured line coordinates. +- `app/modals/toggleEditCardModal.js` - Card editor open/save/archive/duplicate logic, card start/due metadata controls, Created/Updated timestamp display, active-card top-of-list moves from the dropdown/directional controls, debounced + serialized saves, clean-editor reloads after external/MCP card edits, fresh duplicate lifecycle metadata, raw body URL detection/open controls, linked-object paperclip controls including inline URL/app-link entry, AI checklist suggestion preview/insert flow, Obsidian rename reconciliation between `linked_objects` and `related` wikilinks, missing-note status rendering with recreate/relink/remove actions, drag/drop local-file linking, Open With/Obsidian actions, and task-line start/due date controls aligned from measured line coordinates. - `app/listeners/window.js` - Keyboard shortcuts, menu/global-command listeners, Quick Add card modal wiring with board/list selection across open boards, board view switching, Planner toggle/view shortcut handling including all-open-board and current-board date-view scopes, Settings fallback handling, quick board switcher shortcut handling, color cycling, active-card move/archive shortcuts, active-editor closing for workspace-level shortcuts, and the `Cmd/Ctrl + /` helper modal behavior; keep `#modalKeyboardShortcuts` list in sync when adding/changing shortcuts. - `app/init.js` - App bootstrap, folder picker handling, top-level event wiring, Obsidian-vault-required info modal controls, sponsorship modal triggers, and external board-change auto-refresh sync loop, including clean open-editor refreshes. - `app/ui/theme.js` - Theme toggle + OverType theme integration, including the theme shortcut hint/state in the board menu. @@ -67,7 +67,8 @@ This map focuses on source and operational files. Large generated/vendor folders - `lib/cardOrdering.js` - Shared list-card ordering helper used by main-process/MCP restore and move flows to insert a card at the top while renumbering existing files. - `lib/archive.js` - Archive/archive-list filesystem operations plus archive listing/detail/restore helpers and legacy archive fallback handling. - `lib/boardLabels.js` - Board-level label/theme/workflow/External Published Calendar inclusion settings read/write/defaults/filter helpers (`board-settings.md`) plus legacy app-setting extraction for migration. -- `lib/appSettings.js` - App-wide tooltip/notification/Quick Add global shortcut/External Published Calendar settings normalization and JSON persistence under Electron `userData`. +- `lib/appSettings.js` - App-wide tooltip/notification/Quick Add global shortcut/AI assistance, Smart Card Action prompt, and External Published Calendar settings normalization and JSON persistence under Electron `userData`. +- `lib/aiTaskSuggestions.js` - Ollama `/api/tags` model-list inspection, chat request construction, response parsing, Smart Card Action output parsing, checklist task cleanup, and card-context prompt helpers for Card Editor Smart Card Actions. - `lib/externalPublishedCalendar.js` - External Published Calendar event collection and iCalendar feed generation for card due dates and incomplete task due markers. - `lib/obsidianIntegration.js` - Obsidian URI and Signboard deep-link helpers, flat card property normalization, Obsidian vault discovery, managed generated `Signboard Board.base` files with hash-based user-edit protection, linked-note creation/recreation, and linked-note wikilink resolution. - `lib/importers/index.js` - Export surface for board importers. @@ -76,8 +77,9 @@ This map focuses on source and operational files. Large generated/vendor folders - `lib/importers/obsidian.js` - Obsidian importer covering `obsidian-kanban`, generic task scopes, and CardBoard snapshot imports. - `lib/cardBodyEdits.js` - Shared Markdown body-edit helpers for replacing heading sections, inserting text below headings, and appending timestamped note list items. - `lib/boardCreation.js` - Shared default board scaffolding for MCP and CLI-created boards, including default list folders and the starter card body/frontmatter. -- `lib/mcpServer.js` - Headless MCP stdio server for agent access to board/list/card/settings/archive operations inside configured or desktop-trusted roots, safe board creation, archive browse/read/restore tools, Trello/Obsidian/Tasks.md imports, dry-run card writes, and timestamp/task metadata on card tools. -- `lib/cliApp.js` - CLI command parsing/output for `use`, `boards`, `lists`, `cards`, `archive`, `settings`, and path-based `import` commands, including board creation, card duplicate/template commands, `--start` writes, section/note card edits, dry-run previews, JSON timestamp/date output, and `--task-status open|any` for card due filtering. +- `lib/boardDiscovery.js` - Shared known-board discovery for MCP and CLI, including desktop trusted-root reads, last-known desktop open-board state reads, board-looking folder detection, and bounded allowed-root scans. +- `lib/mcpServer.js` - Headless MCP stdio server for agent access to board/list/card/settings/archive operations inside configured or desktop-trusted roots, safe board discovery/creation, archive browse/read/restore tools, Trello/Obsidian/Tasks.md imports, dry-run card writes, and timestamp/task metadata on card tools. +- `lib/cliApp.js` - CLI command parsing/output for `use`, `boards`, `lists`, `cards`, `archive`, `settings`, and path-based `import` commands, including board discovery/creation, card duplicate/template commands, `--start` writes, section/note card edits, dry-run previews, JSON timestamp/date output, and `--task-status open|any` for card due filtering. - `lib/cliBoard.js` - CLI list/card filesystem operations, record loading, card duplication/template creation, section/note body edits, explicit label clearing, due/search/label filtering, start/task-date metadata output, and created/updated age sorting; overdue task filtering defaults to incomplete/open task markers unless callers pass `--task-status any`. - `lib/cliInstall.js` - User-level CLI shim + shell profile installation; packaged shims run `app.asar/bin/signboard.js` under `ELECTRON_RUN_AS_NODE` instead of launching the desktop lifecycle. @@ -85,7 +87,8 @@ This map focuses on source and operational files. Large generated/vendor folders - `scripts/test-frontmatter.js` - Node assertions for frontmatter behavior. - `scripts/test-board-labels.js` - Node assertions for board label settings defaults/migration/filter logic. -- `scripts/test-app-settings.js` - Node assertions for app-wide settings persistence and one-time board-settings migration. +- `scripts/test-app-settings.js` - Node assertions for app-wide settings persistence, including AI settings, and one-time board-settings migration. +- `scripts/test-ai-task-suggestions.js` - Node assertions for Ollama model-list/chat request construction, Smart Card Action output parsing, and AI checklist suggestion cleanup without live network calls. - `scripts/test-board-card-metadata.js` - Board card metadata rendering assertions (compact start/due ranges, labels, task badge behavior). - `scripts/test-board-views.js` - Kanban/Table/Planner rendering and filter helper assertions. - `scripts/test-card-timestamps.js` - Card timestamp normalization assertions for frontmatter, activity, and filesystem fallback behavior. @@ -100,8 +103,8 @@ This map focuses on source and operational files. Large generated/vendor folders - `scripts/migrate-legacy-cards.js` - Bulk migration to YAML frontmatter format. - `scripts/notarize.js` - electron-builder `afterSign` notarization hook. - `scripts/verify-release-assets.js` - Release checklist validator for updater metadata/assets across macOS/Windows/Linux plus curated public-download guidance. -- `scripts/test-mcp-server.js` - MCP protocol smoke test across header + ndjson stdio transports, including trusted-root config/resolution coverage, archive tool coverage, card task metadata assertions, and import-tool coverage. -- `scripts/test-cli.js` - Node CLI smoke test covering list/card/archive flows, duplicate/template card commands, section/note edits, dry-run previews, plus Trello/Obsidian imports. +- `scripts/test-mcp-server.js` - MCP protocol smoke test across header + ndjson stdio transports, including board discovery, trusted-root config/resolution coverage, archive tool coverage, card task metadata assertions, and import-tool coverage. +- `scripts/test-cli.js` - Node CLI smoke test covering board discovery, list/card/archive flows, duplicate/template card commands, section/note edits, dry-run previews, plus Trello/Obsidian imports. - `scripts/test-desktop-cli.js` - Packaged-shim-style Electron Node-mode CLI smoke test, including board creation and import command routing. ## Playwright tests (`tests/playwright/`) @@ -125,7 +128,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `build/entitlements.mac.plist` - macOS hardened runtime entitlements. - `dist/` - Build outputs and unpacked platform artifacts (generated). - `board-settings.md` (runtime, per board folder) - Board settings frontmatter file for labels/color scheme/workflow/External Published Calendar inclusion data; legacy tooltip/notification fields are migrated to app settings and removed on rewrite. -- `app-settings.json` (runtime, Electron `userData`) - App-wide tooltip, notification, Quick Add global shortcut, and External Published Calendar preferences. +- `app-settings.json` (runtime, Electron `userData`) - App-wide tooltip, notification, Quick Add global shortcut, AI assistance, and External Published Calendar preferences. ## Usually ignored for code tasks diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 4473071..db157c8 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -7,7 +7,7 @@ Signboard is a local-first board app built with Electron and plain JavaScript. B - Lists are subdirectories inside that board folder. - Cards are Markdown files in each list directory. - Board-level settings are stored in `board-settings.md` at the board root, including labels, color scheme data, completed-list workflow rules, and whether the board participates in External Published Calendar. -- App-level tooltip, notification, Quick Add global shortcut, and External Published Calendar settings are stored in `app-settings.json` under Electron `userData`. +- App-level tooltip, notification, Quick Add global shortcut, AI assistance/Ollama, and External Published Calendar settings are stored in `app-settings.json` under Electron `userData`. - Obsidian integration is outbound and file-native: boards can live inside a vault, cards gain flat Obsidian-friendly properties on create/write/move, managed `Signboard Board.base` files are auto-created/updated for boards inside detected vaults unless the user customizes them, vault-only card actions can write linked notes in the board root, non-vault linked-note/Base actions show an info modal, legacy `related` Obsidian wikilinks remain readable, structured `linked_objects` render as removable chips for notes/files/folders/URLs/app links/Signboard links, Obsidian-note chips prefer the current `related` wikilink name/open/status/recreate target when Obsidian renames notes, missing Obsidian notes remain linked until the user recreates, relinks, or removes them, Kanban/Table views show linked-object counts, local files can be linked by dragging them onto the card editor, `signboard://open-card?id=...` deep links resolve only through trusted board roots, and `signboard://open-board?path=...` opens validated vault-contained board folders after user confirmation. - The optional Obsidian companion plugin source lives in `obsidian-plugin/`; it can open/copy Signboard links, attach active notes to cards, ask before removing links to deleted notes, handle `obsidian://signboard?...`, and convert a non-root Obsidian folder into a Signboard board after warning the user. - Card metadata is stored in YAML frontmatter (with legacy parser support). @@ -22,7 +22,7 @@ File: `main.js` - Creates a single `BrowserWindow` and loads `index.html`. - Supports a headless MCP server mode when launched with `--mcp-server` (no window created). - Supports `--mcp-config` mode to print MCP client config JSON and exit. -- MCP board-scoped tools use the union of `SIGNBOARD_MCP_ALLOWED_ROOTS` and the desktop app's trusted board roots from `trusted-board-roots.json`; with neither source configured, only non-board config/listing tools are usable. +- MCP board-scoped tools use the union of `SIGNBOARD_MCP_ALLOWED_ROOTS` and the desktop app's trusted board roots from `trusted-board-roots.json`; with neither source configured, only non-board config/listing tools are usable. `signboard_list_boards` is the preferred agent entrypoint for discovering usable board roots because it reports desktop-open/active state, desktop-trusted roots, and bounded allowed-root scan matches. - MCP board-name resolution searches within allowed roots and also matches an allowed root directory itself, so a trusted board root can be either a parent folder or the board folder. - Registers IPC handler `choose-directory` to open native folder picker. - Registers IPC handler `pick-import-sources` to open native file/directory pickers for Trello JSON and Obsidian markdown/vault sources. @@ -30,6 +30,7 @@ File: `main.js` - Registers the `signboard://` protocol in desktop mode, resolves `open-card` links by scanning trusted board roots for matching card IDs, and resolves `open-board` links only for board-looking folders inside an Obsidian vault after prompting before adding a new trusted root. - Builds a native app menu with board view, Settings, theme, and `Check for Updates...` actions, and validates/rebuilds it on focus if required Signboard actions are missing. - Registers the optional app-level Quick Add global shortcut with Electron `globalShortcut` while Signboard is running; the shortcut focuses the main window and opens the same renderer Quick Add card modal as `Cmd/Ctrl + N`. +- Handles opt-in Smart Card Actions by reading app-level Ollama settings and action prompts, verifying/listing local models through Ollama `/api/tags`, and calling the configured Ollama `/api/chat` endpoint through `lib/aiTaskSuggestions.js`. - Runs the opt-in External Published Calendar HTTP server on `127.0.0.1:<port>` while enabled, protected by a stable per-install token in the subscription URL. - Help menu includes `Copy MCP Config` to copy a ready-to-paste Signboard MCP JSON snippet. - In unpackaged/dev mode, Help menu includes `Preview Update Available...` and `Preview Update Ready...` to test updater dialogs without downloading/installing. @@ -44,7 +45,7 @@ File: `main.js` - Owns outbound Obsidian operations through `lib/obsidianIntegration.js`: containing-vault detection, Obsidian URI construction, default-app opening, managed generated Bases files, linked note creation, inbox note appends, and Signboard deep-link copying/resolution. - Owns archive browse/read/restore operations through `lib/archive.js`; renderer code never scans or restores archive contents directly. - Owns adjacent-card top-of-list moves through `moveCardToTop`, backed by `lib/cardOrdering.js`. -- In MCP mode, starts `lib/mcpServer.js`, passes desktop trusted board roots into it, and communicates over stdio using MCP JSON-RPC framing. +- In MCP mode, starts `lib/mcpServer.js`, passes desktop trusted board roots plus the last synced desktop open-board state into it, and communicates over stdio using MCP JSON-RPC framing. - MCP stdio transport supports both `Content-Length` framing and newline-delimited JSON-RPC for client compatibility. - Source checkouts also expose a Node CLI at `bin/signboard.js` for direct terminal board/list/card/archive management. - The packaged Electron executable still recognizes `boards ...`, `lists ...`, `cards ...`, `archive ...`, `settings ...`, and `import ...` CLI invocations through `main.js`, but installed CLI shims should use Electron Node mode instead of initializing the desktop lifecycle. @@ -58,7 +59,7 @@ File: `main.js` File: `preload.js` - Exposes `window.board`, `window.chooser`, and `window.electronAPI`. -- `window.electronAPI` includes external-link opening, clipboard text copying, manual update checks, app settings reads/writes, Quick Add global shortcut status, one-time migration from legacy board-level tooltip/notification settings, and main-process-triggered renderer events such as board view switching and Quick Add. +- `window.electronAPI` includes external-link opening, clipboard text copying, manual update checks, app settings reads/writes, Smart Card Actions, Ollama model inspection, Quick Add global shortcut status, one-time migration from legacy board-level tooltip/notification settings, and main-process-triggered renderer events such as board view switching and Quick Add. - `window.electronAPI.onOpenSignboardCardLink(...)` lets `main.js` hand resolved `signboard://open-card` links to the renderer, which switches to the board and opens the normal card editor. `window.electronAPI.onOpenSignboardBoardLink(...)` switches to a board opened through `signboard://open-board`. - Proxies board operations to `main.js` over `ipcRenderer.invoke(...)`. - Does not use Node filesystem APIs directly. @@ -74,7 +75,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - `index.html` loads vendored libraries and `app/signboard.js` with `defer`. - The left-edge Planner rail and overlay markup live in `index.html`; Planner covers the board header/tabs while open and is hidden when no boards are open. - `app/signboard.js` is concatenated from source modules by `buildjs.sh`. -- Settings includes app-level tooltip/notification/Quick Add global shortcut/External Published Calendar controls and board-specific General, Obsidian, Workflow, Labels, Colors, and Import sections, with import summary/warning rendering in the existing settings modal. +- Settings groups app-level controls into Settings, Notifications, and Smart Actions panels. The Smart Actions panel owns AI assistance, Ollama verification/model selection, and Smart Card Action prompt customization. Current-board settings are ordered General, Labels, Appearance, Workflow, Obsidian, and Import, with import summary/warning rendering in the existing settings modal. - The shared Obsidian-vault-required info modal lives in `index.html` and is controlled from `app/init.js`; linked-note creation and Base generation use it when the active board is not inside a detected vault. - The sponsorship modal is available from the Board menu "Sponsor" item, About modal, and a fixed bottom-right "Sponsor" pill that hides on compact windows so it does not cover lists. - The Board menu now opens a dedicated Archive browser modal; Archive remains hidden from normal board rendering and is not a fourth board view. @@ -84,7 +85,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` ### Board - `window.boardRoot` is the absolute board path with trailing slash. -- Open board tabs are persisted in `localStorage.openBoardPaths` without a hard count limit; the visible tab strip collapses excess tabs behind an `N more` control. +- Open board tabs are persisted in `localStorage.openBoardPaths` without a hard count limit; active/open board state is also mirrored to `open-boards.json` under Electron `userData` for MCP/CLI board discovery, and the visible tab strip collapses excess tabs behind an `N more` control. - Active board root is persisted in `localStorage.activeBoardPath` and mirrored in legacy `localStorage.boardPath` for backward compatibility. - `board-settings.md` is auto-created with default label definitions when missing; legacy tooltip/notification keys are read for app-settings migration and removed on rewrite; board-level External Published Calendar inclusion defaults on and only serializes when disabled. - Imports are additive only: they create new lists/cards in the current board and never modify external source files. @@ -244,7 +245,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Defers list-dropdown moves until macOS native menu tracking has settled before disabling controls or refreshing editor state. - Renders task-line start/due date controls at the start of each parsed checklist line in the editor. - Uses measured textarea line-start coordinates for control placement so wrapped lines do not drift button positions. - - Handles date pickers, labels picker, linked-object paperclip menu, local-file drag/drop linking, duplicate, archive, and Open With actions. + - Handles date pickers, labels picker, linked-object paperclip menu, local-file drag/drop linking, AI checklist item suggestions, duplicate, archive, and Open With actions. - The Open With menu covers default-app file actions and copied Signboard links for every board; Obsidian open/URI actions are shown only when the card is inside a detected vault. - Card duplication now resets archive/lifecycle fields and seeds a fresh `created` event. - `app/utilities/taskList.js`: @@ -397,6 +398,7 @@ Files: `lib/importers/*` - `node bin/signboard.js <command>` - `ELECTRON_RUN_AS_NODE=1 electron ./bin/signboard.js <command>` mirrors the installed packaged CLI shim without initializing the desktop app lifecycle. - `electron . <command>` still routes through the desktop executable dispatch path, but installed CLI shims should prefer Electron Node mode. +- CLI board discovery starts with `signboard boards list --json`, which reports the current CLI board plus desktop-open/trusted boards when available. - CLI board selection is stateful: `signboard use /path/to/board`, then `signboard lists`, `signboard cards`, `signboard archive ...`, `signboard settings`, or `signboard import ...`. - Board creation command: `signboard boards create /path/to/new-board [--use] [--no-welcome] [--json]` or `signboard boards create --parent /path/to --name "New Board" [--json]`. - Import commands: @@ -427,6 +429,7 @@ Files: `lib/importers/*` - Concatenates module files into `app/signboard.js` in strict order. ### CLI internals +- `lib/boardDiscovery.js` owns shared known-board discovery for CLI and MCP, including desktop trusted/open state reads, board-looking folder detection, and bounded allowed-root scans. - `lib/boardCreation.js` owns shared default board scaffolding for MCP and CLI-created boards. - `lib/cliBoard.js` owns CLI list/card filesystem operations, including due filtering with `--due-source any|card|task`, `--task-status open|any`, `--start` card writes, card duplication/template creation, targeted Markdown section edits, timestamped notes, explicit label clearing, and card write dry-run payloads. - `lib/taskList.js` exposes shared task parsing and start/due date helpers for CLI filtering and JSON metadata. @@ -459,7 +462,7 @@ CLI overdue behavior: ### MCP smoke test - `npm run test:mcp` - Script: `scripts/test-mcp-server.js` -- Asserts card tool outputs include `taskSummary` + task date metadata, verifies trusted-root config and board-name resolution, verifies archive browse/read/restore tools, and covers Trello/Obsidian/Tasks.md import tools. +- Asserts card tool outputs include `taskSummary` + task date metadata, verifies trusted/open board discovery, trusted-root config, and board-name resolution, verifies archive browse/read/restore tools, and covers Trello/Obsidian/Tasks.md import tools. ### CLI smoke test - `npm run test:cli` @@ -493,7 +496,12 @@ CLI overdue behavior: ### App settings tests - `npm run test:app-settings` - Script: `scripts/test-app-settings.js` -- Covers app-wide tooltip/notification/Quick Add shortcut/External Published Calendar settings persistence and one-time migration from legacy board settings. +- Covers app-wide tooltip/notification/Quick Add shortcut/AI assistance/External Published Calendar settings persistence and one-time migration from legacy board settings. + +### AI task suggestion tests +- `npm run test:ai-task-suggestions` +- Script: `scripts/test-ai-task-suggestions.js` +- Covers Ollama `/api/tags` model-list request construction, chat request construction, Smart Card Action response parsing, checklist cleanup, and duplicate removal without requiring a running Ollama instance. ### External Published Calendar tests - `npm run test:external-calendar` diff --git a/docs/signboard-cli.md b/docs/signboard-cli.md index 054673e..bb38834 100644 --- a/docs/signboard-cli.md +++ b/docs/signboard-cli.md @@ -64,6 +64,12 @@ You can print the current chosen board by running: signboard use ``` +You can list known boards before choosing one: + +```bash +signboard boards list --json +``` + ## Command Groups The CLI is organized into six command groups: @@ -77,9 +83,11 @@ The CLI is organized into six command groups: ### `boards` -Create a new board folder with Signboard's default starter lists. +List known boards or create a new board folder with Signboard's default starter lists. ```bash +signboard boards list +signboard boards list --json signboard boards create /Path/To/NewBoard signboard boards create /Path/To/NewBoard --use signboard boards create --parent /Path/To --name "New Board" --json @@ -88,6 +96,7 @@ signboard boards create /Path/To/EmptyBoard --no-welcome Notes: +- `boards list --json` reports the current CLI board plus desktop-open and desktop-trusted boards when Signboard has synced that state. - New boards get `000-To-do-stock`, `001-Doing-stock`, `002-Done-stock`, and `XXX-Archive`. - By default, Signboard seeds `000-hello-stock.md` in the To do list with the same starter guidance used by MCP-created boards. - Add `--use` to make the new board the active CLI board for later commands. @@ -321,6 +330,7 @@ Examples: ```bash signboard lists --json +signboard boards list --json signboard cards --due next:7 --json signboard archive cards --search launch --json signboard settings --json @@ -329,11 +339,12 @@ signboard import obsidian --source ~/Vault/Boards --json Recommended agent workflow: -1. Pass `--board <path>` instead of changing global state with `signboard use`. -2. Read before write when references may be ambiguous. -3. Use `--json` for reads and verification. -4. Use `--dry-run --json` before card writes when you need to preview a mutation. -5. Use exact list or card references when possible. +1. Run `signboard boards list --json` to find known usable board roots. +2. Pass `--board <path>` instead of changing global state with `signboard use`. +3. Read before write when references may be ambiguous. +4. Use `--json` for reads and verification. +5. Use `--dry-run --json` before card writes when you need to preview a mutation. +6. Use exact list or card references when possible. ## Common Workflows @@ -439,7 +450,7 @@ signboard settings signboard settings --json ``` -This returns the board settings Markdown document, including labels, color scheme data, workflow settings for completed lists, and whether the board is included in External Published Calendar. App-wide tooltip, notification, Quick Add global shortcut, and External Published Calendar server preferences are desktop app settings, not board settings. +This returns the board settings Markdown document, including labels, color scheme data, workflow settings for completed lists, and whether the board is included in External Published Calendar. App-wide tooltip, notification, Quick Add global shortcut, AI assistance, and External Published Calendar server preferences are desktop app settings, not board settings. Current CLI editing support is intentionally narrow. diff --git a/docs/signboard-feature-showcase-video-outline.md b/docs/signboard-feature-showcase-video-outline.md index 2f3bb32..bb020ec 100644 --- a/docs/signboard-feature-showcase-video-outline.md +++ b/docs/signboard-feature-showcase-video-outline.md @@ -232,16 +232,15 @@ Talk track: Action: - Show App Settings: - - tooltips - - daily due-date reminders - - optional global Quick Add shortcut - - External Published Calendar + - Settings: tooltips and optional global Quick Add shortcut + - Notifications: daily due-date reminders and External Published Calendar + - Smart Actions: AI assistance and Smart Card Action prompts - Show Board General: - rename board - move board folder - Show Labels: - add, rename, color, remove labels -- Show Colors: +- Show Appearance: - board color scheme - light/dark mode - apply to open boards @@ -261,7 +260,7 @@ Talk track: Action: -- In App Settings, show External Published Calendar controls. +- In App Settings > Notifications, show External Published Calendar controls. - Explain the `127.0.0.1` subscription URL. - Show the board Workflow inclusion toggle again if useful. @@ -436,4 +435,3 @@ If the full recording runs too long, keep these core sections: 7. Settings/imports. 8. CLI. 9. MCP. - diff --git a/docs/using-signboard.md b/docs/using-signboard.md index d594918..1874641 100644 --- a/docs/using-signboard.md +++ b/docs/using-signboard.md @@ -110,6 +110,7 @@ Click a card to open it. In the card editor you can: - Open it in Obsidian or the default Markdown app - Open raw web URLs from the card body - Create, open, or remove linked objects +- Use Smart Card Actions when AI assistance is enabled - Archive it The card body is Markdown, so plain text notes, headings, lists, and checklists all work naturally. @@ -340,20 +341,21 @@ Open `Settings` from the board menu or press `Cmd/Ctrl + ,`. ### App Settings -The `App Settings` section controls settings that apply across Signboard: +The `App Settings` group controls settings that apply across Signboard: -- tooltips -- daily due-date reminders -- an optional global Quick Add shortcut while Signboard is open -- External Published Calendar +- `Settings`: tooltips and the optional global Quick Add shortcut while Signboard is open +- `Notifications`: daily due-date reminders and External Published Calendar publishing +- `Smart Actions`: AI assistance through Ollama and Smart Card Action prompts If notifications are enabled, Signboard checks open boards each day at the configured local time and shows a reminder when cards are due. The notification time field is shown only while reminders are enabled. +When AI assistance is enabled, Signboard checks the configured Ollama URL, shows whether it can connect, and loads the locally installed models from Ollama into a model dropdown. Use the refresh button next to the model picker after pulling a new model. The card editor then shows a floating Smart Card Actions button with default actions for generating a new title, generating a task list, and smart paste formatting. App Settings lets you customize each built-in prompt and add custom actions with a label and prompt. Suggestions are previewed before they replace the title or insert Markdown. Card title, body, board/list context, label names, and pasted smart-paste text are sent to the configured Ollama URL only when you use an action. + ### External Published Calendar External Published Calendar is an optional read-only iCalendar feed for local calendar apps. -When enabled in App Settings, Signboard serves a local subscription URL on `127.0.0.1` while Signboard is open. The port and subscription URL settings are shown only while publishing is enabled. Copy the URL from Settings and subscribe to it from your calendar app. The feed is built from boards Signboard has opened and trusted, unless a board is toggled off. +When enabled in `App Settings` > `Notifications`, Signboard serves a local subscription URL on `127.0.0.1` while Signboard is open. The port and subscription URL settings are shown only while publishing is enabled. Copy the URL from Settings and subscribe to it from your calendar app. The feed is built from boards Signboard has opened and trusted, unless a board is toggled off. The feed includes: @@ -366,7 +368,7 @@ The feed hides: - cards in completed lists - boards that are toggled off in that board's Workflow settings -Due items are published as all-day events because Signboard due dates are date-only. The port can be changed in App Settings if the default local port is unavailable. +Due items are published as all-day events because Signboard due dates are date-only. The port can be changed in `App Settings` > `Notifications` if the default local port is unavailable. ### Board General @@ -375,16 +377,6 @@ The board `General` section lets you: - rename the board - move the board folder to a new location -### Workflow - -The `Workflow` section controls which lists count as completed work for the current board. - -Completed-list cards and checked-off task date markers keep their dates, but Planner date views, Planner date filters, board date filters, and daily due reminders hide them by default so finished work does not look actionable. - -Auto-detection is enabled by default. You can turn it off, manually choose completed lists, or uncheck an auto-detected list. - -Workflow also includes the board-level External Published Calendar inclusion toggle. Leave it on to include this board in the app-wide local calendar feed, or turn it off to keep the board out of subscribed calendar apps. - ### Labels The `Labels` section lets you: @@ -396,12 +388,26 @@ The `Labels` section lets you: Labels are stored with the board so each board can have its own vocabulary. -### Colors +### Appearance -The `Colors` section lets you choose a board color scheme. Each scheme includes both light and dark variants. +The `Appearance` section lets you choose a board color scheme. Each scheme includes both light and dark variants. You can also apply the color scheme to all currently open boards. +### Workflow + +The `Workflow` section controls which lists count as completed work for the current board. + +Completed-list cards and checked-off task date markers keep their dates, but Planner date views, Planner date filters, board date filters, and daily due reminders hide them by default so finished work does not look actionable. + +Auto-detection is enabled by default. You can turn it off, manually choose completed lists, or uncheck an auto-detected list. + +Workflow also includes the board-level External Published Calendar inclusion toggle. Leave it on to include this board in the app-wide local calendar feed, or turn it off to keep the board out of subscribed calendar apps. + +### Obsidian + +The `Obsidian` section lets you generate or open the managed `Signboard Board.base` file for boards stored inside an Obsidian vault. + ### Import The `Import` section can bring content into the current board from: diff --git a/index.html b/index.html index 4380397..3d816ba 100644 --- a/index.html +++ b/index.html @@ -433,19 +433,22 @@ <h2 id="boardSettingsTitle">Settings</h2> </div> <div class="board-settings-layout"> <nav class="board-settings-nav" role="tablist" aria-orientation="vertical" aria-label="Settings sections"> - <button id="boardSettingsNavApp" type="button" role="tab" aria-selected="true" aria-controls="boardSettingsPanelApp" class="board-settings-nav-button is-active" data-settings-panel="app">App Settings</button> + <p class="board-settings-nav-label">App Settings</p> + <button id="boardSettingsNavApp" type="button" role="tab" aria-selected="true" aria-controls="boardSettingsPanelApp" class="board-settings-nav-button is-active" data-settings-panel="app">Settings</button> + <button id="boardSettingsNavNotifications" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelNotifications" class="board-settings-nav-button" data-settings-panel="notifications">Notifications</button> + <button id="boardSettingsNavSmartActions" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelSmartActions" class="board-settings-nav-button" data-settings-panel="smart-actions">Smart Actions</button> <div class="board-settings-nav-separator" role="separator"></div> <p class="board-settings-nav-label">Current Board</p> <button id="boardSettingsNavGeneral" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelGeneral" class="board-settings-nav-button" data-settings-panel="general">General</button> - <button id="boardSettingsNavObsidian" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelObsidian" class="board-settings-nav-button" data-settings-panel="obsidian">Obsidian</button> - <button id="boardSettingsNavWorkflow" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelWorkflow" class="board-settings-nav-button" data-settings-panel="workflow">Workflow</button> <button id="boardSettingsNavLabels" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelLabels" class="board-settings-nav-button" data-settings-panel="labels">Labels</button> - <button id="boardSettingsNavColors" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelColors" class="board-settings-nav-button" data-settings-panel="colors">Colors</button> + <button id="boardSettingsNavColors" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelColors" class="board-settings-nav-button" data-settings-panel="colors">Appearance</button> + <button id="boardSettingsNavWorkflow" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelWorkflow" class="board-settings-nav-button" data-settings-panel="workflow">Workflow</button> + <button id="boardSettingsNavObsidian" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelObsidian" class="board-settings-nav-button" data-settings-panel="obsidian">Obsidian</button> <button id="boardSettingsNavImport" type="button" role="tab" aria-selected="false" aria-controls="boardSettingsPanelImport" class="board-settings-nav-button" data-settings-panel="import">Import</button> </nav> <div class="board-settings-panels"> <section id="boardSettingsPanelApp" class="boardSettingsSection board-settings-panel is-active" role="tabpanel" aria-hidden="false" aria-labelledby="boardSettingsNavApp" data-settings-panel="app"> - <h3>App Settings</h3> + <h3>Settings</h3> <div class="board-settings-group"> <div class="board-settings-toggle-row"> <div> @@ -458,6 +461,18 @@ <h3>App Settings</h3> </label> </div> </div> + <div class="board-settings-group"> + <label for="boardSettingsQuickAddShortcut">Global Quick Add shortcut</label> + <div class="board-settings-inline-actions"> + <input id="boardSettingsQuickAddShortcut" type="text" placeholder="CommandOrControl+Shift+Space" spellcheck="false" autocomplete="off"> + <span id="boardSettingsQuickAddShortcutStatus" class="board-settings-status">Disabled</span> + </div> + <p class="boardSettingsHint">Leave blank to disable. Signboard must be open.</p> + </div> + </section> + + <section id="boardSettingsPanelNotifications" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavNotifications" data-settings-panel="notifications"> + <h3>Notifications</h3> <div class="board-settings-group"> <div class="board-settings-toggle-row"> <div> @@ -475,14 +490,6 @@ <h3>App Settings</h3> <input id="boardSettingsNotificationsTime" type="text" value="09:00" placeholder="HH:MM" inputmode="numeric" maxlength="5" width="10" pattern="(?:0[1-9]|1[0-9]|2[0-4]):[0-5][0-9]"> <p class="boardSettingsHint">Use HH:MM in 24-hour format. Hours can be 01 through 24 (example: 21:30).</p> </div> - <div class="board-settings-group"> - <label for="boardSettingsQuickAddShortcut">Global Quick Add shortcut</label> - <div class="board-settings-inline-actions"> - <input id="boardSettingsQuickAddShortcut" type="text" placeholder="CommandOrControl+Shift+Space" spellcheck="false" autocomplete="off"> - <span id="boardSettingsQuickAddShortcutStatus" class="board-settings-status">Disabled</span> - </div> - <p class="boardSettingsHint">Leave blank to disable. Signboard must be open.</p> - </div> <div class="board-settings-group"> <div class="board-settings-toggle-row"> <div> @@ -511,6 +518,43 @@ <h3>App Settings</h3> </div> </section> + <section id="boardSettingsPanelSmartActions" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavSmartActions" data-settings-panel="smart-actions"> + <h3>Smart Actions</h3> + <div class="board-settings-group"> + <div class="board-settings-toggle-row"> + <div> + <p class="boardSettingsHint boardSettingsHintTight">AI assistance</p> + <p class="boardSettingsHint">Use Ollama for local Smart Card Actions in the card editor.</p> + </div> + <label class="board-settings-switch" for="boardSettingsAiToggle" title="Enable AI assistance"> + <input id="boardSettingsAiToggle" type="checkbox" role="switch" aria-label="Enable AI assistance"> + <span class="board-settings-switch-track" aria-hidden="true"></span> + </label> + </div> + </div> + <div id="boardSettingsAiDetails" class="board-settings-group hidden" aria-hidden="true"> + <label for="boardSettingsAiOllamaUrl">Ollama URL</label> + <input id="boardSettingsAiOllamaUrl" type="url" value="http://127.0.0.1:11434" placeholder="http://127.0.0.1:11434" spellcheck="false" autocomplete="off"> + <label for="boardSettingsAiOllamaModel">Ollama model</label> + <div class="board-settings-inline-actions board-settings-ai-model-actions"> + <select id="boardSettingsAiOllamaModel" aria-label="Ollama model"></select> + <button id="btnRefreshAiOllamaModels" type="button" title="Refresh Ollama models" aria-label="Refresh Ollama models"><i data-feather="refresh-cw"></i></button> + <span id="boardSettingsAiOllamaStatus" class="board-settings-status">Not checked</span> + </div> + <label for="boardSettingsAiTaskCount">Suggested task count</label> + <input id="boardSettingsAiTaskCount" type="number" min="3" max="12" step="1" value="6" inputmode="numeric"> + <div class="board-settings-ai-actions-header"> + <div> + <p class="boardSettingsHint boardSettingsHintTight">Smart Card Actions</p> + <p class="boardSettingsHint">Customize the prompts used by card editor AI actions.</p> + </div> + <button id="btnAddAiSmartCardAction" type="button" title="Add Smart Card Action">Add Action</button> + </div> + <div id="boardSettingsAiActionsList" class="board-settings-ai-actions-list"></div> + <p class="boardSettingsHint">Card content is sent to this Ollama URL only when you use an AI action.</p> + </div> + </section> + <section id="boardSettingsPanelGeneral" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavGeneral" data-settings-panel="general"> <h3>Board General</h3> <div class="board-settings-group"> @@ -531,17 +575,31 @@ <h3>Board General</h3> </div> </section> - <section id="boardSettingsPanelObsidian" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavObsidian" data-settings-panel="obsidian"> - <h3>Obsidian</h3> - <p class="boardSettingsHint">Keep this board inside an Obsidian vault to open cards in Obsidian, create linked notes, manage linked objects, and generate a Bases view.</p> - <div class="board-settings-group"> - <h4 class="board-settings-subheading">Bases</h4> - <p class="boardSettingsHint">Generate a <code>Signboard Board.base</code> file in this board folder for Obsidian Bases.</p> - <div class="board-settings-inline-actions board-settings-inline-actions-right"> - <button id="btnGenerateObsidianBase" type="button" title="Generate Obsidian Base">Generate Base</button> - <button id="btnOpenObsidianBase" type="button" title="Open Obsidian Base">Open Base</button> + <section id="boardSettingsPanelLabels" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavLabels" data-settings-panel="labels"> + <h3>Labels</h3> + <p class="boardSettingsHint">Rename labels, choose a color, add new labels, and remove labels.</p> + <div id="boardSettingsLabels"></div> + <button id="btnAddBoardLabel" type="button" title="Add label">Add label</button> + </section> + + <section id="boardSettingsPanelColors" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavColors" data-settings-panel="colors"> + <h3>Appearance</h3> + <p class="boardSettingsHint">Choose a color scheme for this board. Each scheme has a light and dark variant.</p> + <div class="board-color-scheme-selector"> + <select id="boardColorSchemeSelect" aria-label="Color scheme"></select> + </div> + <div class="board-theme-settings-grid"> + <div class="board-theme-settings-mode"> + <h4>Light</h4> + <div id="boardThemeLightPreview" class="board-theme-preview"></div> + </div> + <div class="board-theme-settings-mode"> + <h4>Dark</h4> + <div id="boardThemeDarkPreview" class="board-theme-preview"></div> </div> - <p id="boardSettingsObsidianStatus" class="boardSettingsHint" aria-live="polite"></p> + </div> + <div class="board-theme-settings-actions"> + <button id="btnApplyThemeColorsToOpenBoards" type="button" title="Apply color scheme to all open boards">Apply to all Open Boards</button> </div> </section> @@ -579,31 +637,17 @@ <h4 class="board-settings-subheading">Completed lists</h4> </div> </section> - <section id="boardSettingsPanelLabels" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavLabels" data-settings-panel="labels"> - <h3>Labels</h3> - <p class="boardSettingsHint">Rename labels, choose a color, add new labels, and remove labels.</p> - <div id="boardSettingsLabels"></div> - <button id="btnAddBoardLabel" type="button" title="Add label">Add label</button> - </section> - - <section id="boardSettingsPanelColors" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavColors" data-settings-panel="colors"> - <h3>Color Scheme</h3> - <p class="boardSettingsHint">Choose a color scheme for this board. Each scheme has a light and dark variant.</p> - <div class="board-color-scheme-selector"> - <select id="boardColorSchemeSelect" aria-label="Color scheme"></select> - </div> - <div class="board-theme-settings-grid"> - <div class="board-theme-settings-mode"> - <h4>Light</h4> - <div id="boardThemeLightPreview" class="board-theme-preview"></div> - </div> - <div class="board-theme-settings-mode"> - <h4>Dark</h4> - <div id="boardThemeDarkPreview" class="board-theme-preview"></div> + <section id="boardSettingsPanelObsidian" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavObsidian" data-settings-panel="obsidian"> + <h3>Obsidian</h3> + <p class="boardSettingsHint">Keep this board inside an Obsidian vault to open cards in Obsidian, create linked notes, manage linked objects, and generate a Bases view.</p> + <div class="board-settings-group"> + <h4 class="board-settings-subheading">Bases</h4> + <p class="boardSettingsHint">Generate a <code>Signboard Board.base</code> file in this board folder for Obsidian Bases.</p> + <div class="board-settings-inline-actions board-settings-inline-actions-right"> + <button id="btnGenerateObsidianBase" type="button" title="Generate Obsidian Base">Generate Base</button> + <button id="btnOpenObsidianBase" type="button" title="Open Obsidian Base">Open Base</button> </div> - </div> - <div class="board-theme-settings-actions"> - <button id="btnApplyThemeColorsToOpenBoards" type="button" title="Apply color scheme to all open boards">Apply to all Open Boards</button> + <p id="boardSettingsObsidianStatus" class="boardSettingsHint" aria-live="polite"></p> </div> </section> @@ -659,6 +703,8 @@ <h4>Tasks.md</h4> </div> <div id="cardEditorOpenWithPopover" class="label-popover card-editor-open-with-popover hidden" aria-hidden="true" data-sb-modal-layer></div> <div id="cardEditorLinkedObjectsPopover" class="label-popover card-editor-linked-objects-popover hidden" aria-hidden="true" data-sb-modal-layer></div> + <button type="button" id="cardEditorSmartActionsButton" class="card-editor-smart-actions-button" title="Smart Card Actions" aria-label="Smart Card Actions" aria-expanded="false" hidden><i data-feather="zap"></i></button> + <div id="cardEditorSmartActionsPopover" class="label-popover card-editor-smart-actions-popover hidden" aria-hidden="true" data-sb-modal-layer></div> <button type="button" id="cardEditorCardID" title="Open this card's file" aria-label="Open this card's file"></button> <div> diff --git a/lib/aiTaskSuggestions.js b/lib/aiTaskSuggestions.js new file mode 100644 index 0000000..722506a --- /dev/null +++ b/lib/aiTaskSuggestions.js @@ -0,0 +1,923 @@ +const DEFAULT_OLLAMA_TIMEOUT_MS = 45000; +const DEFAULT_OLLAMA_INSPECT_TIMEOUT_MS = 7000; +const DEFAULT_TASK_COUNT = 6; +const DEFAULT_TASK_NUM_PREDICT = 768; +const DEFAULT_TITLE_NUM_PREDICT = 256; +const DEFAULT_SMART_BODY_NUM_PREDICT = 1800; +const MAX_BODY_CONTEXT_LENGTH = 6000; +const MAX_PASTE_CONTEXT_LENGTH = 12000; +const MAX_TASK_LENGTH = 180; +const MAX_TITLE_LENGTH = 160; +const TASK_COLLECTION_KEYS = [ + 'tasks', + 'taskList', + 'task_list', + 'taskItems', + 'task_items', + 'checklist', + 'items', + 'todos', + 'toDos', + 'todoItems', + 'todo_items', + 'suggestions', + 'steps', + 'actions', + 'nextActions', + 'next_actions', +]; +const TASK_TEXT_KEYS = [ + 'task', + 'title', + 'text', + 'content', + 'name', + 'description', + 'action', + 'item', + 'label', +]; +const SMART_CARD_ACTION_TYPES = new Set(['title', 'tasks', 'paste', 'custom']); + +function isObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function compactText(value, maxLength = 1000) { + const text = String(value || '') + .replace(/\r\n?/g, '\n') + .replace(/\n{4,}/g, '\n\n\n') + .trim(); + + if (!text || text.length <= maxLength) { + return text; + } + + return `${text.slice(0, maxLength).trim()}\n\n[truncated]`; +} + +function normalizeTaskCount(value, fallback = DEFAULT_TASK_COUNT) { + const parsed = Number.parseInt(String(value || ''), 10); + if (Number.isInteger(parsed) && parsed >= 3 && parsed <= 12) { + return parsed; + } + + return fallback; +} + +function normalizeOllamaBaseUrl(value) { + let candidate = String(value || '').trim(); + if (!candidate) { + candidate = 'http://127.0.0.1:11434'; + } + + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { + candidate = `http://${candidate}`; + } + + const parsed = new URL(candidate); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Ollama URL must use http or https.'); + } + + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + + const path = parsed.pathname && parsed.pathname !== '/' + ? parsed.pathname.replace(/\/+$/, '') + : ''; + return `${parsed.origin}${path}`; +} + +function buildOllamaChatUrl(baseUrl) { + return `${normalizeOllamaBaseUrl(baseUrl)}/api/chat`; +} + +function buildOllamaTagsUrl(baseUrl) { + return `${normalizeOllamaBaseUrl(baseUrl)}/api/tags`; +} + +function normalizeSuggestedTaskItem(value) { + let sourceValue = value; + + if (isObject(value)) { + sourceValue = ''; + for (const key of TASK_TEXT_KEYS) { + if (typeof value[key] === 'string' && value[key].trim()) { + sourceValue = value[key]; + break; + } + } + } + + const task = String(sourceValue || '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .replace(/^[-*+โ€ข]\s+/, '') + .replace(/^[-*+]\s+/, '') + .replace(/^\d+[.)]\s+/, '') + .replace(/^\[[ xX]\]\s+/, '') + .replace(/^[-*+โ€ข]\s+\[[ xX]\]\s+/, '') + .replace(/\s+/g, ' ') + .trim(); + + if (!task) { + return ''; + } + + return task.length > MAX_TASK_LENGTH + ? `${task.slice(0, MAX_TASK_LENGTH - 3).trim()}...` + : task; +} + +function normalizeSuggestedTaskItems(tasks, options = {}) { + const maxTasks = normalizeTaskCount(options.maxTasks, DEFAULT_TASK_COUNT); + const sourceTasks = Array.isArray(tasks) ? tasks : []; + const seen = new Set(); + const normalizedTasks = []; + + for (const task of sourceTasks) { + const normalized = normalizeSuggestedTaskItem(task); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + + seen.add(key); + normalizedTasks.push(normalized); + + if (normalizedTasks.length >= maxTasks) { + break; + } + } + + return normalizedTasks; +} + +function extractExistingChecklistItems(body) { + const text = String(body || ''); + const items = []; + const pattern = /^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/gm; + let match = pattern.exec(text); + + while (match) { + const item = normalizeSuggestedTaskItem(match[1]); + if (item) { + items.push(item); + } + match = pattern.exec(text); + } + + return normalizeSuggestedTaskItems(items, { maxTasks: 30 }); +} + +function parseJsonValue(text) { + const raw = String(text || '').trim(); + if (!raw) { + return null; + } + + const fencedJsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidates = [ + raw, + fencedJsonMatch ? fencedJsonMatch[1].trim() : '', + ].filter(Boolean); + + try { + return JSON.parse(raw); + } catch { + // Continue with extracted JSON candidates below. + } + + const firstBrace = raw.indexOf('{'); + const lastBrace = raw.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + candidates.push(raw.slice(firstBrace, lastBrace + 1)); + } + + const firstBracket = raw.indexOf('['); + const lastBracket = raw.lastIndexOf(']'); + if (firstBracket !== -1 && lastBracket > firstBracket) { + const beforeArray = raw.slice(0, firstBracket).trim(); + const arrayCandidate = raw.slice(firstBracket, lastBracket + 1).trim(); + if (raw.trim().startsWith('[') || /^[a-z0-9 _-]+:\s*$/i.test(beforeArray)) { + candidates.push(arrayCandidate); + } + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch { + // Try the next candidate. + } + } + + return null; +} + +function splitSuggestedTaskText(value) { + const text = String(value || '').replace(/\r\n?/g, '\n').trim(); + if (!text) { + return []; + } + + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !/^```/.test(line)) + .filter((line) => !/^[{[\]},]*$/.test(line)) + .filter((line) => !/^(tasks?|checklist|items?|todos?|suggestions)\s*[:=-]?\s*$/i.test(line)) + .filter((line) => !/^["']?(tasks?|checklist|items?|todos?|suggestions)["']?\s*:/i.test(line)) + .filter((line) => !/^(here are|sure\b|certainly\b)/i.test(line)) + .map((line) => line.replace(/,$/, '').replace(/^["']|["']$/g, '').trim()) + .filter(Boolean); +} + +function extractTasksFromParsedValue(parsedValue) { + if (Array.isArray(parsedValue)) { + return parsedValue; + } + + if (typeof parsedValue === 'string') { + return splitSuggestedTaskText(parsedValue); + } + + if (!isObject(parsedValue)) { + return []; + } + + for (const key of TASK_COLLECTION_KEYS) { + const value = parsedValue[key]; + if (Array.isArray(value) && value.length > 0) { + return value; + } + if (typeof value === 'string') { + const nestedJsonTasks = normalizeSuggestedTaskItems(extractTasksFromParsedValue(parseJsonValue(value)), { maxTasks: 30 }); + if (nestedJsonTasks.length > 0) { + return nestedJsonTasks; + } + + const splitTasks = splitSuggestedTaskText(value); + if (splitTasks.length > 0) { + return splitTasks; + } + } + } + + for (const key of ['result', 'data', 'response', 'output']) { + const tasks = extractTasksFromParsedValue(parsedValue[key]); + if (tasks.length > 0) { + return tasks; + } + } + + for (const value of Object.values(parsedValue)) { + if (Array.isArray(value) && value.length > 0) { + return value; + } + } + + return []; +} + +function extractSuggestedTasksFromContent(content, options = {}) { + const parsedValue = parseJsonValue(content); + const parsedTasks = normalizeSuggestedTaskItems(extractTasksFromParsedValue(parsedValue), options); + if (parsedTasks.length > 0 || parsedValue !== null) { + return parsedTasks; + } + + return normalizeSuggestedTaskItems(splitSuggestedTaskText(content), options); +} + +function normalizeSuggestedTitle(value) { + const title = String(value || '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .replace(/^["']|["']$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (!title) { + return ''; + } + + return title.length > MAX_TITLE_LENGTH + ? `${title.slice(0, MAX_TITLE_LENGTH - 3).trim()}...` + : title; +} + +function extractSuggestedTitleFromContent(content) { + const parsedValue = parseJsonValue(content); + if (isObject(parsedValue)) { + for (const key of ['title', 'newTitle', 'new_title', 'suggestedTitle', 'suggested_title']) { + const title = normalizeSuggestedTitle(parsedValue[key]); + if (title) { + return title; + } + } + } + + if (typeof parsedValue === 'string') { + const title = normalizeSuggestedTitle(parsedValue); + if (title) { + return title; + } + } + + if (parsedValue !== null) { + return ''; + } + + return normalizeSuggestedTitle(String(content || '').split('\n')[0]); +} + +function normalizeSuggestedMarkdownBody(value) { + const body = String(value || '') + .replace(/\r\n?/g, '\n') + .replace(/^```(?:markdown|md)?\s*/i, '') + .replace(/```$/i, '') + .trim(); + + return body; +} + +function extractSuggestedMarkdownBodyFromContent(content) { + const parsedValue = parseJsonValue(content); + if (isObject(parsedValue)) { + for (const key of ['body', 'markdown', 'content', 'text', 'notes']) { + const body = normalizeSuggestedMarkdownBody(parsedValue[key]); + if (body) { + return body; + } + } + } + + if (typeof parsedValue === 'string') { + const body = normalizeSuggestedMarkdownBody(parsedValue); + if (body) { + return body; + } + } + + if (parsedValue !== null) { + return ''; + } + + return normalizeSuggestedMarkdownBody(content); +} + +function normalizeSmartCardAction(actionDefinition = {}) { + const source = isObject(actionDefinition) ? actionDefinition : {}; + const id = String(source.id || '').trim(); + const inferredType = id === 'generate-title' + ? 'title' + : (id === 'generate-task-list' ? 'tasks' : (id === 'smart-paste' ? 'paste' : 'custom')); + const type = SMART_CARD_ACTION_TYPES.has(source.type) ? source.type : inferredType; + const label = String(source.label || '').replace(/\s+/g, ' ').trim() || 'Smart Card Action'; + const prompt = String(source.prompt || '').replace(/\r\n?/g, '\n').trim(); + + return { + id: id || type, + type, + label, + prompt, + builtIn: source.builtIn === true, + }; +} + +function getSmartCardActionNumPredict(action) { + if (action.type === 'title') { + return DEFAULT_TITLE_NUM_PREDICT; + } + + if (action.type === 'tasks') { + return DEFAULT_TASK_NUM_PREDICT; + } + + return DEFAULT_SMART_BODY_NUM_PREDICT; +} + +function getSmartCardActionOutputInstruction(action, taskCount) { + if (action.type === 'title') { + return 'Return JSON only in this shape: {"title":"Improved card title"}.'; + } + + if (action.type === 'tasks') { + return `Return JSON only in this shape: {"tasks":["Task one","Task two"]}. Return ${taskCount} concrete tasks whenever possible.`; + } + + return 'Return JSON only in this shape: {"body":"Markdown text to add to the card"}. Use Markdown text only inside the body value.'; +} + +function buildSmartCardActionMessages(actionDefinition = {}, cardContext = {}, options = {}) { + const action = normalizeSmartCardAction(actionDefinition); + const taskCount = normalizeTaskCount(options.taskCount, DEFAULT_TASK_COUNT); + const existingTasks = Array.isArray(cardContext.existingTasks) + ? normalizeSuggestedTaskItems(cardContext.existingTasks, { maxTasks: 30 }) + : extractExistingChecklistItems(cardContext.body); + const pastedText = compactText(options.pasteText || cardContext.pasteText, MAX_PASTE_CONTEXT_LENGTH); + + const context = { + action: { + id: action.id, + type: action.type, + label: action.label, + }, + board: compactText(cardContext.boardName, 120), + list: compactText(cardContext.listName, 120), + title: compactText(cardContext.title, 240), + labels: Array.isArray(cardContext.labels) + ? cardContext.labels.map((label) => compactText(label, 80)).filter(Boolean).slice(0, 12) + : [], + existingTasks, + body: compactText(cardContext.body, MAX_BODY_CONTEXT_LENGTH), + pastedText, + requestedTaskCount: taskCount, + currentDate: compactText(options.currentDate, 32), + }; + + return [ + { + role: 'system', + content: [ + 'You run Smart Card Actions for Signboard Markdown cards.', + 'Follow the action prompt and use the card context.', + 'Do not duplicate existing checklist items.', + 'Do not invent facts.', + 'Do not include code fences around the JSON response.', + getSmartCardActionOutputInstruction(action, taskCount), + ].join(' '), + }, + { + role: 'user', + content: [ + `Action label: ${action.label}`, + 'Action prompt:', + action.prompt, + 'Card context:', + JSON.stringify(context), + ].join('\n\n'), + }, + ]; +} + +function buildCardTaskSuggestionMessages(cardContext = {}, options = {}) { + const taskCount = normalizeTaskCount(options.taskCount, DEFAULT_TASK_COUNT); + const existingTasks = Array.isArray(cardContext.existingTasks) + ? normalizeSuggestedTaskItems(cardContext.existingTasks, { maxTasks: 30 }) + : extractExistingChecklistItems(cardContext.body); + + const context = { + board: compactText(cardContext.boardName, 120), + list: compactText(cardContext.listName, 120), + title: compactText(cardContext.title, 240), + labels: Array.isArray(cardContext.labels) + ? cardContext.labels.map((label) => compactText(label, 80)).filter(Boolean).slice(0, 12) + : [], + existingTasks, + body: compactText(cardContext.body, MAX_BODY_CONTEXT_LENGTH), + requestedTaskCount: taskCount, + currentDate: compactText(options.currentDate, 32), + }; + + return [ + { + role: 'system', + content: [ + 'You suggest practical checklist items for a Signboard Markdown card.', + 'Infer common next actions from the card title, body, board, list, and labels.', + `Return ${taskCount} concrete tasks whenever possible, and never return an empty tasks array.`, + 'If the card has sparse context, infer common tasks from the title and list name.', + 'Do not duplicate existing checklist items.', + 'Use short imperative task text.', + 'Do not include Markdown checkbox prefixes.', + 'Return JSON only in this shape: {"tasks":["Task one","Task two"]}.', + ].join(' '), + }, + { + role: 'user', + content: [ + `Suggest ${taskCount} useful checklist tasks for this card.`, + 'Return only a JSON object with a non-empty "tasks" array.', + JSON.stringify(context), + ].join('\n\n'), + }, + ]; +} + +async function parseErrorBody(response) { + try { + const body = await response.text(); + if (!body) { + return ''; + } + + try { + const parsed = JSON.parse(body); + return typeof parsed.error === 'string' ? parsed.error : body; + } catch { + return body.slice(0, 300); + } + } catch { + return ''; + } +} + +function createAiError(message, code, details = null) { + const error = new Error(message); + error.code = code; + if (isObject(details)) { + error.details = details; + } + return error; +} + +function normalizeOllamaModelEntry(entry) { + if (!isObject(entry)) { + return null; + } + + const name = String(entry.name || entry.model || '').trim(); + if (!name) { + return null; + } + + return { + name, + model: String(entry.model || name).trim() || name, + modifiedAt: typeof entry.modified_at === 'string' ? entry.modified_at : '', + size: Number.isFinite(entry.size) ? entry.size : 0, + digest: typeof entry.digest === 'string' ? entry.digest : '', + details: isObject(entry.details) ? { ...entry.details } : {}, + }; +} + +function normalizeOllamaModelList(models) { + const sourceModels = Array.isArray(models) ? models : []; + const seen = new Set(); + const normalizedModels = []; + + for (const model of sourceModels) { + const normalized = normalizeOllamaModelEntry(model); + if (!normalized || seen.has(normalized.name)) { + continue; + } + + seen.add(normalized.name); + normalizedModels.push(normalized); + } + + return normalizedModels.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function listOllamaModels(ollamaSettings = {}, options = {}) { + const sourceSettings = isObject(ollamaSettings) ? ollamaSettings : {}; + const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw createAiError('This runtime cannot inspect Ollama.', 'AI_FETCH_UNAVAILABLE'); + } + + const endpoint = buildOllamaTagsUrl(sourceSettings.url); + const timeoutMs = Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : DEFAULT_OLLAMA_INSPECT_TIMEOUT_MS; + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeout = controller + ? setTimeout(() => controller.abort(), timeoutMs) + : null; + + let response; + try { + response = await fetchImpl(endpoint, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller ? controller.signal : undefined, + }); + } catch (error) { + if (error && error.name === 'AbortError') { + throw createAiError('Ollama did not respond before the request timed out.', 'AI_REQUEST_TIMEOUT'); + } + + throw createAiError(`Unable to reach Ollama at ${endpoint}.`, 'AI_CONNECTION_FAILED'); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + + if (!response || !response.ok) { + const status = response && response.status ? response.status : 'unknown'; + const errorBody = response ? await parseErrorBody(response) : ''; + throw createAiError( + errorBody ? `Ollama model list failed (${status}): ${errorBody}` : `Ollama model list failed (${status}).`, + 'AI_PROVIDER_ERROR', + ); + } + + const data = await response.json(); + return { + url: normalizeOllamaBaseUrl(sourceSettings.url), + models: normalizeOllamaModelList(data && data.models), + }; +} + +async function suggestCardTasksWithOllama(ollamaSettings = {}, cardContext = {}, options = {}) { + const sourceSettings = isObject(ollamaSettings) ? ollamaSettings : {}; + const model = String(sourceSettings.model || '').trim(); + if (!model) { + throw createAiError('Choose an Ollama model in App Settings.', 'AI_MODEL_MISSING'); + } + + const taskCount = normalizeTaskCount(sourceSettings.taskCount, DEFAULT_TASK_COUNT); + const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw createAiError('This runtime cannot make AI requests.', 'AI_FETCH_UNAVAILABLE'); + } + + const endpoint = buildOllamaChatUrl(sourceSettings.url); + const requestBody = { + model, + stream: false, + think: false, + format: 'json', + options: { + temperature: 0.2, + num_predict: DEFAULT_TASK_NUM_PREDICT, + }, + messages: buildCardTaskSuggestionMessages(cardContext, { + taskCount, + currentDate: options.currentDate, + }), + }; + + const timeoutMs = Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : DEFAULT_OLLAMA_TIMEOUT_MS; + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeout = controller + ? setTimeout(() => controller.abort(), timeoutMs) + : null; + + let response; + try { + response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: controller ? controller.signal : undefined, + }); + } catch (error) { + if (error && error.name === 'AbortError') { + throw createAiError('Ollama did not respond before the request timed out.', 'AI_REQUEST_TIMEOUT'); + } + + throw createAiError(`Unable to reach Ollama at ${endpoint}.`, 'AI_CONNECTION_FAILED'); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + + if (!response || !response.ok) { + const status = response && response.status ? response.status : 'unknown'; + const errorBody = response ? await parseErrorBody(response) : ''; + throw createAiError( + errorBody ? `Ollama request failed (${status}): ${errorBody}` : `Ollama request failed (${status}).`, + 'AI_PROVIDER_ERROR', + ); + } + + let data; + try { + data = await response.json(); + } catch (error) { + throw createAiError('Ollama returned a response that was not valid JSON.', 'AI_PROVIDER_ERROR', { + provider: 'ollama', + endpoint, + model, + parseError: error && error.message ? String(error.message) : String(error || ''), + }); + } + + const responseMessage = data && isObject(data.message) ? data.message : {}; + const content = typeof responseMessage.content === 'string' + ? responseMessage.content + : (typeof data.response === 'string' ? data.response : ''); + const tasks = extractSuggestedTasksFromContent(content, { maxTasks: taskCount }); + + if (tasks.length === 0) { + const hasThinking = typeof responseMessage.thinking === 'string' && responseMessage.thinking.trim().length > 0; + const doneReason = typeof data.done_reason === 'string' ? data.done_reason : ''; + const debugDetails = { + provider: 'ollama', + endpoint, + model, + request: { + think: requestBody.think, + numPredict: requestBody.options.num_predict, + format: requestBody.format, + }, + responseContent: content, + doneReason, + responseHasThinking: hasThinking, + thinkingLength: hasThinking ? responseMessage.thinking.length : 0, + response: data, + }; + const usedBudgetThinking = !content.trim() && hasThinking && doneReason === 'length'; + + throw createAiError( + usedBudgetThinking + ? 'The model spent its response budget thinking and did not return tasks.' + : 'The model did not return any usable tasks.', + usedBudgetThinking ? 'AI_THINKING_RESPONSE_TRUNCATED' : 'AI_EMPTY_SUGGESTIONS', + debugDetails, + ); + } + + return { + tasks, + model, + provider: 'ollama', + }; +} + +async function runSmartCardActionWithOllama(ollamaSettings = {}, actionDefinition = {}, cardContext = {}, options = {}) { + const sourceSettings = isObject(ollamaSettings) ? ollamaSettings : {}; + const model = String(sourceSettings.model || '').trim(); + if (!model) { + throw createAiError('Choose an Ollama model in App Settings.', 'AI_MODEL_MISSING'); + } + + const action = normalizeSmartCardAction(actionDefinition); + if (!action.prompt) { + throw createAiError('Smart Card Action prompt is empty.', 'AI_ACTION_PROMPT_MISSING'); + } + + const taskCount = normalizeTaskCount(sourceSettings.taskCount, DEFAULT_TASK_COUNT); + const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw createAiError('This runtime cannot make AI requests.', 'AI_FETCH_UNAVAILABLE'); + } + + const endpoint = buildOllamaChatUrl(sourceSettings.url); + const requestBody = { + model, + stream: false, + think: false, + format: 'json', + options: { + temperature: action.type === 'title' ? 0.15 : 0.2, + num_predict: getSmartCardActionNumPredict(action), + }, + messages: buildSmartCardActionMessages(action, cardContext, { + taskCount, + currentDate: options.currentDate, + pasteText: options.pasteText, + }), + }; + + const timeoutMs = Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : DEFAULT_OLLAMA_TIMEOUT_MS; + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeout = controller + ? setTimeout(() => controller.abort(), timeoutMs) + : null; + + let response; + try { + response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: controller ? controller.signal : undefined, + }); + } catch (error) { + if (error && error.name === 'AbortError') { + throw createAiError('Ollama did not respond before the request timed out.', 'AI_REQUEST_TIMEOUT'); + } + + throw createAiError(`Unable to reach Ollama at ${endpoint}.`, 'AI_CONNECTION_FAILED'); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + + if (!response || !response.ok) { + const status = response && response.status ? response.status : 'unknown'; + const errorBody = response ? await parseErrorBody(response) : ''; + throw createAiError( + errorBody ? `Ollama request failed (${status}): ${errorBody}` : `Ollama request failed (${status}).`, + 'AI_PROVIDER_ERROR', + ); + } + + let data; + try { + data = await response.json(); + } catch (error) { + throw createAiError('Ollama returned a response that was not valid JSON.', 'AI_PROVIDER_ERROR', { + provider: 'ollama', + endpoint, + model, + action, + parseError: error && error.message ? String(error.message) : String(error || ''), + }); + } + + const responseMessage = data && isObject(data.message) ? data.message : {}; + const content = typeof responseMessage.content === 'string' + ? responseMessage.content + : (typeof data.response === 'string' ? data.response : ''); + const result = { + actionId: action.id, + actionType: action.type, + label: action.label, + model, + provider: 'ollama', + }; + + if (action.type === 'title') { + result.title = extractSuggestedTitleFromContent(content); + } else if (action.type === 'tasks') { + result.tasks = extractSuggestedTasksFromContent(content, { maxTasks: taskCount }); + } else { + result.body = extractSuggestedMarkdownBodyFromContent(content); + } + + const hasOutput = Boolean( + result.title || + (Array.isArray(result.tasks) && result.tasks.length > 0) || + result.body, + ); + + if (!hasOutput) { + const hasThinking = typeof responseMessage.thinking === 'string' && responseMessage.thinking.trim().length > 0; + const doneReason = typeof data.done_reason === 'string' ? data.done_reason : ''; + const debugDetails = { + provider: 'ollama', + endpoint, + model, + action, + request: { + think: requestBody.think, + numPredict: requestBody.options.num_predict, + format: requestBody.format, + }, + responseContent: content, + doneReason, + responseHasThinking: hasThinking, + thinkingLength: hasThinking ? responseMessage.thinking.length : 0, + response: data, + }; + const usedBudgetThinking = !content.trim() && hasThinking && doneReason === 'length'; + + throw createAiError( + usedBudgetThinking + ? 'The model spent its response budget thinking and did not return a Smart Card Action result.' + : 'The model did not return a usable Smart Card Action result.', + usedBudgetThinking ? 'AI_THINKING_RESPONSE_TRUNCATED' : 'AI_EMPTY_ACTION_RESULT', + debugDetails, + ); + } + + return result; +} + +module.exports = { + DEFAULT_OLLAMA_INSPECT_TIMEOUT_MS, + DEFAULT_OLLAMA_TIMEOUT_MS, + DEFAULT_TASK_COUNT, + DEFAULT_TASK_NUM_PREDICT, + DEFAULT_SMART_BODY_NUM_PREDICT, + DEFAULT_TITLE_NUM_PREDICT, + buildCardTaskSuggestionMessages, + buildSmartCardActionMessages, + buildOllamaChatUrl, + buildOllamaTagsUrl, + extractExistingChecklistItems, + extractSuggestedMarkdownBodyFromContent, + extractSuggestedTasksFromContent, + extractSuggestedTitleFromContent, + listOllamaModels, + normalizeSmartCardAction, + normalizeOllamaBaseUrl, + normalizeOllamaModelList, + normalizeSuggestedTaskItem, + normalizeSuggestedTaskItems, + normalizeTaskCount, + runSmartCardActionWithOllama, + suggestCardTasksWithOllama, +}; diff --git a/lib/appSettings.js b/lib/appSettings.js index 574f111..39b8e4c 100644 --- a/lib/appSettings.js +++ b/lib/appSettings.js @@ -2,8 +2,50 @@ const fs = require('fs').promises; const path = require('path'); const APP_SETTINGS_FILE_NAME = 'app-settings.json'; -const APP_SETTINGS_VERSION = 3; +const APP_SETTINGS_VERSION = 4; const DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT = 48273; +const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434'; +const DEFAULT_OLLAMA_MODEL = 'llama3.2'; +const SMART_CARD_ACTION_LABEL_MAX_LENGTH = 80; +const SMART_CARD_ACTION_PROMPT_MAX_LENGTH = 6000; +const CUSTOM_SMART_CARD_ACTION_LIMIT = 12; +const DEFAULT_SMART_CARD_ACTIONS = Object.freeze([ + Object.freeze({ + id: 'generate-title', + type: 'title', + label: 'Generate new title', + prompt: [ + 'Improve the card title using the current title and card body.', + 'Keep it concise, specific, and action-oriented.', + 'Preserve the original intent and do not add facts that are not supported by the card.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'generate-task-list', + type: 'tasks', + label: 'Generate task list', + prompt: [ + 'Generate practical checklist items for this card.', + 'Infer common next actions from the title, body, board, list, and labels.', + 'Do not duplicate existing checklist items.', + 'Use short imperative task text.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'smart-paste', + type: 'paste', + label: 'Smart paste', + prompt: [ + 'Format the pasted information for this Signboard Markdown card.', + 'Preserve the complete useful information from the pasted text, including names, dates, decisions, links, and requirements.', + 'Use clear Markdown sections, a concise summary when helpful, task list items for follow-up work, and reference URLs when present.', + 'Do not invent facts.', + ].join('\n'), + builtIn: true, + }), +]); const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({ enabled: false, time: '09:00', @@ -17,6 +59,16 @@ const DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS = Object.freeze({ port: DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, token: '', }); +const DEFAULT_AI_SETTINGS = Object.freeze({ + enabled: false, + provider: 'ollama', + ollama: Object.freeze({ + url: DEFAULT_OLLAMA_URL, + model: DEFAULT_OLLAMA_MODEL, + taskCount: 6, + }), + smartCardActions: DEFAULT_SMART_CARD_ACTIONS, +}); const GLOBAL_SHORTCUT_MAX_LENGTH = 80; function isObject(value) { @@ -91,6 +143,178 @@ function normalizeExternalPublishedCalendarSettings(rawCalendarSettings) { }; } +function normalizeAiProvider(value) { + return value === 'ollama' ? 'ollama' : DEFAULT_AI_SETTINGS.provider; +} + +function normalizeOllamaUrl(value) { + let candidate = String(value || '').trim(); + if (!candidate) { + candidate = DEFAULT_AI_SETTINGS.ollama.url; + } + + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { + candidate = `http://${candidate}`; + } + + try { + const parsed = new URL(candidate); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return DEFAULT_AI_SETTINGS.ollama.url; + } + + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + + const basePath = parsed.pathname && parsed.pathname !== '/' + ? parsed.pathname.replace(/\/+$/, '') + : ''; + return `${parsed.origin}${basePath}`; + } catch { + return DEFAULT_AI_SETTINGS.ollama.url; + } +} + +function normalizeOllamaModel(value) { + const candidate = String(value || '').trim(); + if (!candidate || candidate.length > 120 || /[\s\x00-\x1F]/.test(candidate)) { + return DEFAULT_AI_SETTINGS.ollama.model; + } + + return candidate; +} + +function normalizeAiTaskCount(value) { + const parsedCount = Number.parseInt(String(value || ''), 10); + if (Number.isInteger(parsedCount) && parsedCount >= 3 && parsedCount <= 12) { + return parsedCount; + } + + return DEFAULT_AI_SETTINGS.ollama.taskCount; +} + +function cloneDefaultSmartCardActions() { + return DEFAULT_SMART_CARD_ACTIONS.map((action) => ({ ...action })); +} + +function normalizeSmartCardActionLabel(value, fallback = '') { + const candidate = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + const normalizedFallback = String(fallback || '').trim(); + if (!candidate) { + return normalizedFallback; + } + + return candidate.slice(0, SMART_CARD_ACTION_LABEL_MAX_LENGTH).trim() || normalizedFallback; +} + +function normalizeSmartCardActionPrompt(value, fallback = '') { + const candidate = String(value || '') + .replace(/\r\n?/g, '\n') + .trim(); + const normalizedFallback = String(fallback || '').trim(); + if (!candidate) { + return normalizedFallback; + } + + return candidate.slice(0, SMART_CARD_ACTION_PROMPT_MAX_LENGTH).trim() || normalizedFallback; +} + +function normalizeSmartCardActionId(value, fallback = '') { + const candidate = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + const normalizedFallback = String(fallback || '').trim(); + return candidate || normalizedFallback; +} + +function normalizeSmartCardActions(rawActions) { + const sourceActions = Array.isArray(rawActions) ? rawActions : []; + const normalizedActions = []; + const seenIds = new Set(); + + for (const defaultAction of DEFAULT_SMART_CARD_ACTIONS) { + const sourceAction = sourceActions.find((action) => ( + isObject(action) && String(action.id || '') === defaultAction.id + )); + + normalizedActions.push({ + ...defaultAction, + label: defaultAction.label, + prompt: normalizeSmartCardActionPrompt( + sourceAction && Object.prototype.hasOwnProperty.call(sourceAction, 'prompt') + ? sourceAction.prompt + : defaultAction.prompt, + defaultAction.prompt, + ), + builtIn: true, + }); + seenIds.add(defaultAction.id); + } + + let customCount = 0; + for (const action of sourceActions) { + if (!isObject(action) || action.builtIn === true) { + continue; + } + + const label = normalizeSmartCardActionLabel(action.label); + const prompt = normalizeSmartCardActionPrompt(action.prompt); + if (!label || !prompt) { + continue; + } + + const fallbackId = `custom-${customCount + 1}`; + let id = normalizeSmartCardActionId(action.id, fallbackId); + if (DEFAULT_SMART_CARD_ACTIONS.some((defaultAction) => defaultAction.id === id)) { + id = fallbackId; + } + while (seenIds.has(id)) { + id = `custom-${customCount + 1}-${seenIds.size + 1}`; + } + + normalizedActions.push({ + id, + type: 'custom', + label, + prompt, + builtIn: false, + }); + seenIds.add(id); + customCount += 1; + if (customCount >= CUSTOM_SMART_CARD_ACTION_LIMIT) { + break; + } + } + + return normalizedActions; +} + +function normalizeOllamaSettings(rawOllamaSettings) { + const source = isObject(rawOllamaSettings) ? rawOllamaSettings : {}; + return { + url: normalizeOllamaUrl(source.url), + model: normalizeOllamaModel(source.model), + taskCount: normalizeAiTaskCount(source.taskCount), + }; +} + +function normalizeAiSettings(rawAiSettings) { + const source = isObject(rawAiSettings) ? rawAiSettings : {}; + return { + enabled: source.enabled === true, + provider: normalizeAiProvider(source.provider), + ollama: normalizeOllamaSettings(source.ollama), + smartCardActions: normalizeSmartCardActions(source.smartCardActions || source.cardActions), + }; +} + function normalizeAppSettings(rawSettings = {}) { const source = isObject(rawSettings) ? rawSettings : {}; const migration = isObject(source.migration) ? source.migration : {}; @@ -101,6 +325,7 @@ function normalizeAppSettings(rawSettings = {}) { tooltipsEnabled: normalizeTooltipsEnabled(source.tooltipsEnabled), quickAdd: normalizeQuickAddSettings(source.quickAdd), externalPublishedCalendar: normalizeExternalPublishedCalendarSettings(source.externalPublishedCalendar), + ai: normalizeAiSettings(source.ai), migration: { boardSettingsMigrated: migration.boardSettingsMigrated === true, sourceBoardRoot: typeof migration.sourceBoardRoot === 'string' ? migration.sourceBoardRoot : '', @@ -168,6 +393,10 @@ async function updateAppSettings(userDataPath, partialSettings = {}) { next.externalPublishedCalendar = normalizeExternalPublishedCalendarSettings(partialSettings.externalPublishedCalendar); } + if (Object.prototype.hasOwnProperty.call(partialSettings, 'ai')) { + next.ai = normalizeAiSettings(partialSettings.ai); + } + if (Object.prototype.hasOwnProperty.call(partialSettings, 'migration')) { next.migration = { ...normalizeAppSettings(current).migration, @@ -211,12 +440,24 @@ module.exports = { DEFAULT_QUICK_ADD_SETTINGS: () => ({ ...DEFAULT_QUICK_ADD_SETTINGS }), DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS: () => ({ ...DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS }), DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, + DEFAULT_AI_SETTINGS: () => ({ + ...DEFAULT_AI_SETTINGS, + ollama: { ...DEFAULT_AI_SETTINGS.ollama }, + smartCardActions: cloneDefaultSmartCardActions(), + }), + DEFAULT_SMART_CARD_ACTIONS: cloneDefaultSmartCardActions, normalizeAppSettings, + normalizeAiSettings, + normalizeAiTaskCount, normalizeExternalPublishedCalendarPort, normalizeExternalPublishedCalendarSettings, normalizeGlobalShortcutAccelerator, normalizeNotificationSettings, + normalizeOllamaModel, + normalizeOllamaSettings, + normalizeOllamaUrl, normalizeQuickAddSettings, + normalizeSmartCardActions, normalizeTooltipsEnabled, readAppSettings, writeAppSettings, diff --git a/lib/boardDiscovery.js b/lib/boardDiscovery.js new file mode 100644 index 0000000..830952d --- /dev/null +++ b/lib/boardDiscovery.js @@ -0,0 +1,434 @@ +const fs = require('fs').promises; +const os = require('os'); +const path = require('path'); + +const TRUSTED_BOARD_ROOTS_FILE = 'trusted-board-roots.json'; +const OPEN_BOARDS_STATE_FILE = 'open-boards.json'; +const DEFAULT_BOARD_SCAN_DEPTH = 2; +const DEFAULT_BOARD_SCAN_LIMIT = 100; +const MAX_BOARD_SCAN_DEPTH = 8; +const MAX_BOARD_SCAN_LIMIT = 500; +const SKIPPED_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', 'node_modules']); + +const boardSortCollator = new Intl.Collator(undefined, { + usage: 'sort', + sensitivity: 'base', + numeric: true, + ignorePunctuation: true, + localeMatcher: 'lookup', +}); + +function normalizeAbsolutePath(rawPath) { + const input = typeof rawPath === 'string' ? rawPath.trim() : ''; + if (!input) { + return ''; + } + + const resolved = path.resolve(input); + return path.isAbsolute(resolved) ? resolved : ''; +} + +function normalizePathList(paths) { + const seen = new Set(); + const normalizedPaths = []; + + for (const rawPath of Array.isArray(paths) ? paths : []) { + const normalizedPath = normalizeAbsolutePath(rawPath); + if (!normalizedPath || seen.has(normalizedPath)) { + continue; + } + + seen.add(normalizedPath); + normalizedPaths.push(normalizedPath); + } + + return normalizedPaths; +} + +function isPathInside(parentPath, childPath) { + const normalizedParent = normalizeAbsolutePath(parentPath); + const normalizedChild = normalizeAbsolutePath(childPath); + if (!normalizedParent || !normalizedChild) { + return false; + } + + const relativePath = path.relative(normalizedParent, normalizedChild); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function isPathInsideAnyRoot(rootPaths, childPath) { + return normalizePathList(rootPaths).some((rootPath) => isPathInside(rootPath, childPath)); +} + +function normalizeInteger(value, fallback, minimum, maximum) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.min(maximum, Math.max(minimum, Math.trunc(parsed))); +} + +async function pathExistsAsDirectory(directoryPath) { + try { + const stats = await fs.stat(directoryPath); + return stats.isDirectory(); + } catch (error) { + if (error && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function pathLooksLikeSignboardBoardRoot(directoryPath) { + const boardRoot = normalizeAbsolutePath(directoryPath); + if (!boardRoot) { + return false; + } + + if (!await pathExistsAsDirectory(boardRoot)) { + return false; + } + + let entries = []; + try { + entries = await fs.readdir(boardRoot, { withFileTypes: true }); + } catch { + return false; + } + + return entries.some((entry) => { + if (entry.isFile() && entry.name === 'board-settings.md') { + return true; + } + + if (!entry.isDirectory()) { + return false; + } + + return entry.name === 'XXX-Archive' || /^\d{3}-/.test(entry.name); + }); +} + +function normalizeOpenBoardsState(rawState = {}) { + const openBoardRoots = normalizePathList( + rawState.openBoardRoots || rawState.openBoardPaths || rawState.boards || [] + ); + const activeBoardRoot = normalizeAbsolutePath(rawState.activeBoardRoot || rawState.activeBoardPath || ''); + + return { + openBoardRoots, + activeBoardRoot, + }; +} + +function getDefaultDesktopUserDataDir({ + env = process.env, + platform = process.platform, + homeDir = os.homedir(), + userDataDir = '', +} = {}) { + const explicitUserDataDir = normalizeAbsolutePath(userDataDir); + if (explicitUserDataDir) { + return explicitUserDataDir; + } + + const envUserDataDir = normalizeAbsolutePath(env.SIGNBOARD_DESKTOP_USER_DATA_DIR || ''); + if (envUserDataDir) { + return envUserDataDir; + } + + const resolvedHomeDir = normalizeAbsolutePath(homeDir); + if (platform === 'win32') { + const appDataDir = normalizeAbsolutePath(env.APPDATA || ''); + if (appDataDir) { + return path.join(appDataDir, 'Signboard'); + } + } + + if (platform === 'darwin') { + return resolvedHomeDir + ? path.join(resolvedHomeDir, 'Library', 'Application Support', 'Signboard') + : ''; + } + + const xdgConfigHome = normalizeAbsolutePath(env.XDG_CONFIG_HOME || ''); + if (xdgConfigHome) { + return path.join(xdgConfigHome, 'Signboard'); + } + + return resolvedHomeDir ? path.join(resolvedHomeDir, '.config', 'Signboard') : ''; +} + +async function readJsonFile(filePath, fallbackValue) { + try { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw); + } catch (error) { + if ((error && error.code === 'ENOENT') || error instanceof SyntaxError) { + return fallbackValue; + } + throw error; + } +} + +async function readDesktopTrustedBoardRoots(options = {}) { + const userDataDir = getDefaultDesktopUserDataDir(options); + if (!userDataDir) { + return []; + } + + const parsed = await readJsonFile(path.join(userDataDir, TRUSTED_BOARD_ROOTS_FILE), []); + return normalizePathList(Array.isArray(parsed) ? parsed : []); +} + +async function readDesktopOpenBoardsState(options = {}) { + const userDataDir = getDefaultDesktopUserDataDir(options); + if (!userDataDir) { + return normalizeOpenBoardsState(); + } + + const parsed = await readJsonFile(path.join(userDataDir, OPEN_BOARDS_STATE_FILE), {}); + return normalizeOpenBoardsState(parsed && typeof parsed === 'object' ? parsed : {}); +} + +function addBoardCandidate(candidateMap, rawBoardRoot, source, orderHint) { + const boardRoot = normalizeAbsolutePath(rawBoardRoot); + if (!boardRoot) { + return null; + } + + let candidate = candidateMap.get(boardRoot); + if (!candidate) { + candidate = { + boardRoot, + sources: new Set(), + order: Number.isFinite(orderHint) ? orderHint : candidateMap.size + 1000, + }; + candidateMap.set(boardRoot, candidate); + } + + candidate.sources.add(source); + if (Number.isFinite(orderHint)) { + candidate.order = Math.min(candidate.order, orderHint); + } + + return candidate; +} + +async function scanAllowedRootForBoards(allowedRoot, options = {}) { + const rootPath = normalizeAbsolutePath(allowedRoot); + if (!rootPath || !await pathExistsAsDirectory(rootPath)) { + return []; + } + + const maxDepth = normalizeInteger( + options.maxDepth, + DEFAULT_BOARD_SCAN_DEPTH, + 0, + MAX_BOARD_SCAN_DEPTH, + ); + const limit = normalizeInteger( + options.limit, + DEFAULT_BOARD_SCAN_LIMIT, + 1, + MAX_BOARD_SCAN_LIMIT, + ); + const matches = []; + const seen = new Set([rootPath]); + + if (await pathLooksLikeSignboardBoardRoot(rootPath)) { + return [rootPath]; + } + + const queue = [{ directoryPath: rootPath, depth: 0 }]; + while (queue.length > 0 && matches.length < limit) { + const current = queue.shift(); + + let entries = []; + try { + entries = await fs.readdir(current.directoryPath, { withFileTypes: true }); + } catch { + continue; + } + + entries.sort((left, right) => boardSortCollator.compare(left.name, right.name)); + + for (const entry of entries) { + if (matches.length >= limit) { + break; + } + + if (!entry.isDirectory() || SKIPPED_DIRECTORY_NAMES.has(entry.name)) { + continue; + } + + const candidatePath = path.resolve(current.directoryPath, entry.name); + if ( + seen.has(candidatePath) || + !isPathInside(current.directoryPath, candidatePath) + ) { + continue; + } + + seen.add(candidatePath); + if (await pathLooksLikeSignboardBoardRoot(candidatePath)) { + matches.push(candidatePath); + continue; + } + + if (current.depth < maxDepth) { + queue.push({ + directoryPath: candidatePath, + depth: current.depth + 1, + }); + } + } + } + + return matches; +} + +async function buildBoardRecord(candidate, context) { + const sources = Array.from(candidate.sources).sort(); + const boardRoot = candidate.boardRoot; + const exists = await pathExistsAsDirectory(boardRoot); + const isBoardRoot = exists ? await pathLooksLikeSignboardBoardRoot(boardRoot) : false; + + return { + name: path.basename(boardRoot), + boardRoot, + sources, + isOpen: sources.includes('desktop-open'), + isActive: boardRoot === context.activeBoard, + isCurrent: boardRoot === context.currentBoard, + isTrusted: context.trustedBoardRootSet.has(boardRoot), + isAllowed: context.allowedRoots.length === 0 + ? context.allowWhenNoAllowedRoots + : isPathInsideAnyRoot(context.allowedRoots, boardRoot), + exists, + isBoardRoot, + order: candidate.order, + }; +} + +async function discoverBoards(options = {}) { + const allowedRoots = normalizePathList(options.allowedRoots); + const trustedBoardRoots = normalizePathList(options.trustedBoardRoots); + const openBoardState = normalizeOpenBoardsState({ + openBoardRoots: options.openBoardRoots, + activeBoardRoot: options.activeBoardRoot, + }); + const currentBoard = normalizeAbsolutePath(options.currentBoardRoot || ''); + const activeBoard = openBoardState.activeBoardRoot || currentBoard || openBoardState.openBoardRoots[0] || ''; + const includeAllowedRootScan = options.includeAllowedRootScan !== false; + const maxDepth = normalizeInteger( + options.maxDepth, + DEFAULT_BOARD_SCAN_DEPTH, + 0, + MAX_BOARD_SCAN_DEPTH, + ); + const limit = normalizeInteger( + options.limit, + DEFAULT_BOARD_SCAN_LIMIT, + 1, + MAX_BOARD_SCAN_LIMIT, + ); + const candidateMap = new Map(); + + if (openBoardState.activeBoardRoot) { + addBoardCandidate(candidateMap, openBoardState.activeBoardRoot, 'desktop-active', 0); + } + + if (currentBoard) { + addBoardCandidate(candidateMap, currentBoard, 'cli-current', 1); + } + + openBoardState.openBoardRoots.forEach((boardRoot, index) => { + addBoardCandidate(candidateMap, boardRoot, 'desktop-open', 10 + index); + }); + + trustedBoardRoots.forEach((boardRoot, index) => { + addBoardCandidate(candidateMap, boardRoot, 'desktop-trusted', 200 + index); + }); + + if (includeAllowedRootScan) { + let scanOrder = 1000; + for (const allowedRoot of allowedRoots) { + const matches = await scanAllowedRootForBoards(allowedRoot, { maxDepth, limit }); + for (const match of matches) { + const source = match === allowedRoot ? 'mcp-allowed-root' : 'mcp-allowed-root-scan'; + addBoardCandidate(candidateMap, match, source, scanOrder); + scanOrder += 1; + if (candidateMap.size >= limit) { + break; + } + } + } + } + + const context = { + activeBoard, + currentBoard, + trustedBoardRootSet: new Set(trustedBoardRoots), + allowedRoots, + allowWhenNoAllowedRoots: options.allowWhenNoAllowedRoots === true, + }; + const boards = await Promise.all( + Array.from(candidateMap.values()).map((candidate) => buildBoardRecord(candidate, context)) + ); + + boards.sort((left, right) => { + if (left.isActive !== right.isActive) { + return left.isActive ? -1 : 1; + } + + if (left.isOpen !== right.isOpen) { + return left.isOpen ? -1 : 1; + } + + if (left.isCurrent !== right.isCurrent) { + return left.isCurrent ? -1 : 1; + } + + if (left.order !== right.order) { + return left.order - right.order; + } + + const byName = boardSortCollator.compare(left.name, right.name); + return byName !== 0 ? byName : left.boardRoot.localeCompare(right.boardRoot); + }); + + for (const board of boards) { + delete board.order; + } + + return { + ok: true, + activeBoard, + currentBoard, + openBoardRoots: openBoardState.openBoardRoots, + trustedBoardRoots, + allowedRoots, + boards: boards.slice(0, limit), + boardCount: Math.min(boards.length, limit), + }; +} + +module.exports = { + TRUSTED_BOARD_ROOTS_FILE, + OPEN_BOARDS_STATE_FILE, + DEFAULT_BOARD_SCAN_DEPTH, + DEFAULT_BOARD_SCAN_LIMIT, + MAX_BOARD_SCAN_DEPTH, + MAX_BOARD_SCAN_LIMIT, + normalizeAbsolutePath, + normalizePathList, + normalizeOpenBoardsState, + getDefaultDesktopUserDataDir, + readDesktopTrustedBoardRoots, + readDesktopOpenBoardsState, + pathLooksLikeSignboardBoardRoot, + discoverBoards, +}; diff --git a/lib/cliApp.js b/lib/cliApp.js index b5b25c1..f548bd7 100644 --- a/lib/cliApp.js +++ b/lib/cliApp.js @@ -31,6 +31,11 @@ const { setCurrentBoard, } = require('./cliState'); const { createBoard: createBoardOnDisk } = require('./boardCreation'); +const { + discoverBoards, + readDesktopOpenBoardsState, + readDesktopTrustedBoardRoots, +} = require('./boardDiscovery'); const CLI_GROUPS = new Set(['use', 'boards', 'lists', 'cards', 'archive', 'settings', 'import']); const CLI_NEAR_MISSES = new Set(['board', 'list', 'card']); @@ -141,6 +146,7 @@ function renderHelpText(commandName = 'signboard') { 'Usage:', ` ${commandName} use <board-root>`, '', + ` ${commandName} boards list [--json]`, ` ${commandName} boards create <board-root> [--no-welcome] [--use] [--json]`, ` ${commandName} boards create --parent <parent-root> --name <board-name> [--no-welcome] [--use] [--json]`, '', @@ -691,12 +697,68 @@ function resolveBoardCreateRoot(positionals, options, usage) { return path.resolve(String(parentRoot), validateBoardNameSegment(boardName)); } +function renderBoardsListText(discovery) { + const boards = Array.isArray(discovery.boards) ? discovery.boards : []; + if (boards.length === 0) { + return [ + 'No known boards found.', + 'Open a board in Signboard, run `signboard use /path/to/board`, or pass `--board <path>` to board commands.', + ].join('\n'); + } + + return boards.map((board) => { + const labels = []; + if (board.isActive) labels.push('active'); + if (board.isCurrent) labels.push('current'); + if (board.isOpen) labels.push('open'); + if (board.isTrusted) labels.push('trusted'); + if (!board.exists) labels.push('missing'); + + const marker = board.isActive || board.isCurrent ? '*' : '-'; + const labelText = labels.length > 0 ? ` (${labels.join(', ')})` : ''; + return `${marker} ${board.name}${labelText}\n ${board.boardRoot}`; + }).join('\n'); +} + +async function runBoardsListCommand(positionals, options, context, usage) { + assertAllowedOptions(options, ['json', 'board'], usage); + assertNoExtraPositionals(positionals, 0, usage); + + const explicitBoard = getOption(options, 'board'); + const currentBoardRoot = explicitBoard + ? path.resolve(String(explicitBoard)) + : await getCurrentBoard(context.stateOptions); + const trustedBoardRoots = await readDesktopTrustedBoardRoots(); + const openBoardsState = await readDesktopOpenBoardsState(); + const discovery = await discoverBoards({ + trustedBoardRoots, + openBoardRoots: openBoardsState.openBoardRoots, + activeBoardRoot: openBoardsState.activeBoardRoot, + currentBoardRoot, + includeAllowedRootScan: false, + allowWhenNoAllowedRoots: true, + }); + + if (getOption(options, 'json') === true) { + context.stdout.write(`${JSON.stringify(discovery, null, 2)}\n`); + return 0; + } + + context.stdout.write(`${renderBoardsListText(discovery)}\n`); + return 0; +} + async function runBoardsCommand(action, positionals, options, context) { const usage = [ - `Usage: ${context.commandName || 'signboard'} boards create <board-root> [--no-welcome] [--use] [--json]`, + `Usage: ${context.commandName || 'signboard'} boards list [--json]`, + ` ${context.commandName || 'signboard'} boards create <board-root> [--no-welcome] [--use] [--json]`, ` ${context.commandName || 'signboard'} boards create --parent <parent-root> --name <board-name> [--no-welcome] [--use] [--json]`, ].join('\n'); + if (!action || action === 'list') { + return runBoardsListCommand(positionals, options, context, usage); + } + if (action !== 'create') { throw new Error(usage); } diff --git a/lib/mcpServer.js b/lib/mcpServer.js index 3b123eb..ee1a3be 100644 --- a/lib/mcpServer.js +++ b/lib/mcpServer.js @@ -27,6 +27,14 @@ const { importTasksMd, } = require('./importers'); const { createBoard: createBoardOnDisk } = require('./boardCreation'); +const { + DEFAULT_BOARD_SCAN_DEPTH, + DEFAULT_BOARD_SCAN_LIMIT, + MAX_BOARD_SCAN_DEPTH, + MAX_BOARD_SCAN_LIMIT, + discoverBoards, + normalizeOpenBoardsState, +} = require('./boardDiscovery'); const JSON_RPC_VERSION = '2.0'; const MCP_PROTOCOL_VERSION = '2025-11-25'; @@ -97,6 +105,28 @@ const INTERNAL_TOOL_DEFINITIONS = [ properties: {}, }, }, + { + name: 'signboard.list_boards', + description: 'List known usable Signboard board roots from desktop-open, desktop-trusted, and configured MCP roots.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + includeAllowedRootScan: { + type: 'boolean', + description: 'Scan configured allowed roots for board-looking folders. Defaults to true.', + }, + maxDepth: { + type: 'integer', + description: `Allowed-root scan depth, 0-${MAX_BOARD_SCAN_DEPTH}. Defaults to ${DEFAULT_BOARD_SCAN_DEPTH}.`, + }, + limit: { + type: 'integer', + description: `Maximum boards to return, 1-${MAX_BOARD_SCAN_LIMIT}. Defaults to ${DEFAULT_BOARD_SCAN_LIMIT}.`, + }, + }, + }, + }, { name: 'signboard.resolve_board_by_name', description: 'Resolve board root paths by board directory name under configured allowed roots.', @@ -1046,11 +1076,13 @@ function buildToolError(message, details) { function buildServerConfig(options = {}) { const trustedBoardRoots = normalizeAllowedRootList(options.trustedBoardRoots); + const desktopOpenBoardsState = normalizeOpenBoardsState(options.desktopOpenBoardsState); return { appVersion: String(options.appVersion || '0.0.0'), readOnly: parseBoolean(process.env.SIGNBOARD_MCP_READ_ONLY, DEFAULT_READ_ONLY), allowedRoots: mergeAllowedRoots(parseAllowedRoots(), trustedBoardRoots), trustedBoardRoots, + desktopOpenBoardsState, }; } @@ -1064,6 +1096,8 @@ async function handleToolCall(config, name, args = {}) { readOnly: config.readOnly, allowedRoots: config.allowedRoots, trustedBoardRoots: config.trustedBoardRoots, + openBoardRoots: config.desktopOpenBoardsState.openBoardRoots, + activeBoardRoot: config.desktopOpenBoardsState.activeBoardRoot, protocolVersion: MCP_PROTOCOL_VERSION, appVersion: config.appVersion, }; @@ -1077,6 +1111,18 @@ async function handleToolCall(config, name, args = {}) { }; } + case 'signboard.list_boards': { + return discoverBoards({ + allowedRoots: config.allowedRoots, + trustedBoardRoots: config.trustedBoardRoots, + openBoardRoots: config.desktopOpenBoardsState.openBoardRoots, + activeBoardRoot: config.desktopOpenBoardsState.activeBoardRoot, + includeAllowedRootScan: args.includeAllowedRootScan !== false, + maxDepth: args.maxDepth, + limit: args.limit, + }); + } + case 'signboard.resolve_board_by_name': { const boardName = String(args.boardName || '').trim(); if (!boardName) { diff --git a/main.js b/main.js index 9cb1756..ecd3eba 100644 --- a/main.js +++ b/main.js @@ -27,12 +27,17 @@ const { } = require('./lib/archive'); const boardLabels = require('./lib/boardLabels'); const appSettings = require('./lib/appSettings'); +const { listOllamaModels, runSmartCardActionWithOllama, suggestCardTasksWithOllama } = require('./lib/aiTaskSuggestions'); const { buildExternalPublishedCalendarFeed } = require('./lib/externalPublishedCalendar'); const { importTrello, importObsidian, importTasksMd } = require('./lib/importers'); const obsidianIntegration = require('./lib/obsidianIntegration'); const { startSignboardMcpServer } = require('./lib/mcpServer'); const { isCliInvocation, runCli } = require('./lib/cliApp'); const { installCliForCurrentUser } = require('./lib/cliInstall'); +const { + OPEN_BOARDS_STATE_FILE, + normalizeOpenBoardsState, +} = require('./lib/boardDiscovery'); const GITHUB_OWNER = 'cdevroe'; const GITHUB_REPO = 'signboard'; @@ -202,6 +207,10 @@ function getTrustedBoardRootsPath() { return path.join(app.getPath('userData'), TRUSTED_BOARD_ROOTS_FILE); } +function getOpenBoardsStatePath() { + return path.join(app.getPath('userData'), OPEN_BOARDS_STATE_FILE); +} + function readTrustedBoardRoots() { if (trustedBoardRootsCache) { return new Set(trustedBoardRootsCache); @@ -245,6 +254,40 @@ function writeTrustedBoardRoots(roots) { } } +function readOpenBoardsState() { + try { + const raw = fs.readFileSync(getOpenBoardsStatePath(), 'utf8'); + const parsed = JSON.parse(raw); + return normalizeOpenBoardsState(parsed && typeof parsed === 'object' ? parsed : {}); + } catch (error) { + if (error && error.code !== 'ENOENT') { + console.error('Failed to read open board state.', error); + } + return normalizeOpenBoardsState(); + } +} + +function writeOpenBoardsState(state) { + const normalizedState = normalizeOpenBoardsState(state); + + try { + fs.mkdirSync(app.getPath('userData'), { recursive: true }); + fs.writeFileSync( + getOpenBoardsStatePath(), + JSON.stringify({ + openBoardRoots: normalizedState.openBoardRoots, + activeBoardRoot: normalizedState.activeBoardRoot, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8' + ); + } catch (error) { + console.error('Failed to write open board state.', error); + } + + return normalizedState; +} + function addTrustedBoardRoot(boardRoot) { const normalizedRoot = normalizeBoardRootPath(boardRoot); if (!normalizedRoot) { @@ -2872,6 +2915,207 @@ async function initializeAppRuntimeSettings() { await applyExternalPublishedCalendarSettings(settings); } +function getAiTaskSuggestionErrorMessage(error) { + const code = error && error.code ? String(error.code) : ''; + + if (code === 'AI_CONNECTION_FAILED') { + return 'Unable to reach Ollama. Check the Ollama URL and make sure Ollama is running.'; + } + + if (code === 'AI_REQUEST_TIMEOUT') { + return 'Ollama took too long to respond.'; + } + + if (code === 'AI_MODEL_MISSING') { + return 'Choose an Ollama model in App Settings.'; + } + + if (code === 'AI_EMPTY_SUGGESTIONS') { + return 'The model did not return any usable tasks.'; + } + + if (code === 'AI_EMPTY_ACTION_RESULT') { + return 'The model did not return a usable Smart Card Action result.'; + } + + if (code === 'AI_ACTION_PROMPT_MISSING') { + return 'This Smart Card Action needs a prompt in App Settings.'; + } + + if (code === 'AI_THINKING_RESPONSE_TRUNCATED') { + return 'The model spent its response budget thinking and did not return a usable result. Try again or choose a non-thinking model.'; + } + + return error && error.message + ? String(error.message) + : 'Unable to suggest tasks.'; +} + +function getAiTaskSuggestionDebugDetails(error) { + const details = error && error.details && typeof error.details === 'object' + ? error.details + : null; + return details; +} + +function logAiTaskSuggestionDebugDetails(error) { + const details = getAiTaskSuggestionDebugDetails(error); + if (!details) { + return; + } + + try { + console.error('AI task suggestion debug details:', JSON.stringify(details, null, 2)); + } catch { + console.error('AI task suggestion debug details:', details); + } +} + +function getOllamaInspectionErrorMessage(error) { + const code = error && error.code ? String(error.code) : ''; + + if (code === 'AI_CONNECTION_FAILED') { + return 'Unable to reach Ollama. Check the URL and make sure Ollama is running.'; + } + + if (code === 'AI_REQUEST_TIMEOUT') { + return 'Ollama did not respond.'; + } + + return error && error.message + ? String(error.message) + : 'Unable to inspect Ollama.'; +} + +async function inspectOllama(payload = {}) { + const rawSettings = await appSettings.readAppSettings(app.getPath('userData')); + const settings = appSettings.normalizeAppSettings(rawSettings); + const payloadSource = payload && typeof payload === 'object' ? payload : {}; + const url = Object.prototype.hasOwnProperty.call(payloadSource, 'url') + ? payloadSource.url + : settings.ai.ollama.url; + + try { + const result = await listOllamaModels({ url }); + const modelCount = result.models.length; + return { + ok: true, + url: result.url, + models: result.models, + message: modelCount === 1 + ? 'Connected. Found 1 model.' + : `Connected. Found ${modelCount} models.`, + }; + } catch (error) { + console.error('Unable to inspect Ollama.', error); + return { + ok: false, + error: error && error.code ? String(error.code) : 'AI_OLLAMA_INSPECTION_FAILED', + url: typeof url === 'string' ? url : '', + models: [], + message: getOllamaInspectionErrorMessage(error), + }; + } +} + +async function suggestCardTasks(payload = {}) { + const rawSettings = await appSettings.readAppSettings(app.getPath('userData')); + const settings = appSettings.normalizeAppSettings(rawSettings); + + if (!settings.ai.enabled) { + return { + ok: false, + error: 'AI_DISABLED', + message: 'AI assistance is disabled in App Settings.', + }; + } + + if (settings.ai.provider !== 'ollama') { + return { + ok: false, + error: 'AI_PROVIDER_UNSUPPORTED', + message: 'Only Ollama AI assistance is currently supported.', + }; + } + + try { + const result = await suggestCardTasksWithOllama(settings.ai.ollama, payload, { + currentDate: new Date().toISOString().slice(0, 10), + }); + return { + ok: true, + ...result, + }; + } catch (error) { + console.error('Unable to suggest card tasks.', error); + logAiTaskSuggestionDebugDetails(error); + const debugDetails = getAiTaskSuggestionDebugDetails(error); + return { + ok: false, + error: error && error.code ? String(error.code) : 'AI_TASK_SUGGESTION_FAILED', + message: getAiTaskSuggestionErrorMessage(error), + ...(debugDetails ? { debug: debugDetails } : {}), + }; + } +} + +async function runSmartCardAction(payload = {}) { + const rawSettings = await appSettings.readAppSettings(app.getPath('userData')); + const settings = appSettings.normalizeAppSettings(rawSettings); + + if (!settings.ai.enabled) { + return { + ok: false, + error: 'AI_DISABLED', + message: 'AI assistance is disabled in App Settings.', + }; + } + + if (settings.ai.provider !== 'ollama') { + return { + ok: false, + error: 'AI_PROVIDER_UNSUPPORTED', + message: 'Only Ollama AI assistance is currently supported.', + }; + } + + const payloadSource = payload && typeof payload === 'object' ? payload : {}; + const actionId = String(payloadSource.actionId || '').trim(); + const action = settings.ai.smartCardActions.find((candidate) => candidate.id === actionId); + if (!action) { + return { + ok: false, + error: 'AI_ACTION_MISSING', + message: 'Smart Card Action is unavailable.', + }; + } + + const cardContext = payloadSource.context && typeof payloadSource.context === 'object' + ? payloadSource.context + : payloadSource; + + try { + const result = await runSmartCardActionWithOllama(settings.ai.ollama, action, cardContext, { + currentDate: new Date().toISOString().slice(0, 10), + pasteText: typeof payloadSource.pasteText === 'string' ? payloadSource.pasteText : '', + }); + return { + ok: true, + ...result, + }; + } catch (error) { + console.error('Unable to run Smart Card Action.', error); + logAiTaskSuggestionDebugDetails(error); + const debugDetails = getAiTaskSuggestionDebugDetails(error); + return { + ok: false, + error: error && error.code ? String(error.code) : 'AI_SMART_CARD_ACTION_FAILED', + message: getAiTaskSuggestionErrorMessage(error), + ...(debugDetails ? { debug: debugDetails } : {}), + }; + } +} + function buildMcpConfigTemplate() { const command = process.execPath; const args = app.isPackaged ? [MCP_SERVER_ARG] : [app.getAppPath(), MCP_SERVER_ARG]; @@ -3756,6 +4000,12 @@ ipcMain.handle('board-call', async (event, payload = {}) => { return result; } + case 'syncOpenBoardsState': + return { + ok: true, + ...writeOpenBoardsState(args[0]), + }; + case 'clearActiveBoardRoot': { await stopBoardWatchForSender(event.sender); const state = getSenderBoardAccessState(event.sender); @@ -4465,6 +4715,18 @@ ipcMain.handle('update-app-settings', async (_event, partialSettings = {}) => ( ipcMain.handle('get-global-shortcut-status', async () => getQuickAddGlobalShortcutStatus()); +ipcMain.handle('suggest-card-tasks', async (_event, payload = {}) => ( + suggestCardTasks(payload) +)); + +ipcMain.handle('run-smart-card-action', async (_event, payload = {}) => ( + runSmartCardAction(payload) +)); + +ipcMain.handle('inspect-ollama', async (_event, payload = {}) => ( + inspectOllama(payload) +)); + ipcMain.handle('copy-text-to-clipboard', async (_event, text = '') => { clipboard.writeText(String(text || '')); return { ok: true }; @@ -4542,6 +4804,7 @@ if (isCliMode) { await startSignboardMcpServer({ appVersion: app.getVersion(), trustedBoardRoots: Array.from(readTrustedBoardRoots()), + desktopOpenBoardsState: readOpenBoardsState(), onStop: () => { if (isMcpPowerSaveBlockerActive()) { powerSaveBlocker.stop(mcpPowerSaveBlockerId); diff --git a/package.json b/package.json index 7b35a4d..a1b0096 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:card-ordering": "node scripts/test-card-ordering.js", "test:board-labels": "node scripts/test-board-labels.js", "test:app-settings": "node scripts/test-app-settings.js", + "test:ai-task-suggestions": "node scripts/test-ai-task-suggestions.js", "test:mcp": "node scripts/test-mcp-server.js", "test:board-card-metadata": "node scripts/test-board-card-metadata.js", "test:board-views": "node scripts/test-board-views.js", diff --git a/preload.js b/preload.js index 4db83a4..c612051 100644 --- a/preload.js +++ b/preload.js @@ -41,6 +41,7 @@ contextBridge.exposeInMainWorld('board', { authorizeBoardSelection: async (selectionToken) => invokeBoard('authorizeBoardSelection', selectionToken), adoptLegacyBoardRoots: async (boardRoots) => invokeBoard('adoptLegacyBoardRoots', boardRoots), setActiveBoardRoot: async (boardRoot) => invokeBoard('setActiveBoardRoot', boardRoot), + syncOpenBoardsState: async (state) => invokeBoard('syncOpenBoardsState', state), clearActiveBoardRoot: async () => invokeBoard('clearActiveBoardRoot'), listLists: async (root) => invokeBoard('listLists', root), listCards: async (listPath) => invokeBoard('listCards', listPath), @@ -128,6 +129,9 @@ contextBridge.exposeInMainWorld('electronAPI', { readAppSettings: () => ipcRenderer.invoke('read-app-settings'), updateAppSettings: (partialSettings) => ipcRenderer.invoke('update-app-settings', partialSettings), getGlobalShortcutStatus: () => ipcRenderer.invoke('get-global-shortcut-status'), + suggestCardTasks: (payload) => ipcRenderer.invoke('suggest-card-tasks', payload), + runSmartCardAction: (payload) => ipcRenderer.invoke('run-smart-card-action', payload), + inspectOllama: (payload) => ipcRenderer.invoke('inspect-ollama', payload), migrateAppSettingsFromBoard: (boardRoot) => ipcRenderer.invoke('migrate-app-settings-from-board', boardRoot), copyTextToClipboard: (text) => ipcRenderer.invoke('copy-text-to-clipboard', text), openExternal: (url) => ipcRenderer.invoke('open-external-url', url), diff --git a/readme.md b/readme.md index db588dd..78f65e9 100644 --- a/readme.md +++ b/readme.md @@ -27,6 +27,7 @@ Signboard is free for personal use. If you are using Signboard for your work it - โœ… Progress counters on cards - ๐Ÿ”Ž Live search - ๐Ÿ—„๏ธ Linked files and URLs on cards +- โœจ Optional local Ollama Smart Card Actions for titles, task lists, smart paste, and custom prompts - ๐Ÿงฒ Drag-and-drop card movement - โšก Unlimited open boards with overflow tabs and a quick switcher - โŒจ๏ธ Keyboard shortcuts @@ -107,7 +108,7 @@ Signboard includes a built-in MCP server so agents can interact with local board - Dedicated instructions: [MCP_README.md](./MCP_README.md) - To copy config: `Help` -> `Copy MCP Config` -- MCP uses both explicit allowed roots and Signboard's desktop trusted board roots for board lookup. +- MCP uses `signboard_list_boards` plus both explicit allowed roots and Signboard's desktop trusted/open board state for board lookup. - Optional agent skill: `skills/signboard-mcp/SKILL.md` ## ๐Ÿ’ป CLI @@ -117,6 +118,7 @@ Signboard includes a terminal CLI for direct board management without going thro - Full guide: [docs/signboard-cli.md](./docs/signboard-cli.md) - In the desktop app on macOS/Linux: `Help` -> `Install Signboard CLI` +- Use `signboard boards list --json` to list known boards before choosing one - Use `signboard use /Path/to/Board` once to remember the active board for later commands - Use `signboard boards create /Path/to/NewBoard --use` to create and select a new board from the terminal - The installed `signboard` wrapper runs the bundled CLI in Electron's Node mode, avoiding desktop app startup for terminal commands. @@ -125,6 +127,7 @@ Examples: ```bash # Select a board once +signboard boards list --json signboard use /Path/to/Board # Create a board @@ -223,6 +226,7 @@ npm start npm run test:frontmatter npm run test:board-labels npm run test:app-settings +npm run test:ai-task-suggestions npm run test:board-card-metadata npm run test:due-notifications npm run test:task-list diff --git a/scripts/test-ai-task-suggestions.js b/scripts/test-ai-task-suggestions.js new file mode 100644 index 0000000..e33d387 --- /dev/null +++ b/scripts/test-ai-task-suggestions.js @@ -0,0 +1,333 @@ +const assert = require('assert'); + +const { + DEFAULT_TASK_NUM_PREDICT, + DEFAULT_SMART_BODY_NUM_PREDICT, + DEFAULT_TITLE_NUM_PREDICT, + buildOllamaChatUrl, + buildOllamaTagsUrl, + extractExistingChecklistItems, + extractSuggestedMarkdownBodyFromContent, + extractSuggestedTasksFromContent, + extractSuggestedTitleFromContent, + listOllamaModels, + normalizeOllamaBaseUrl, + normalizeOllamaModelList, + normalizeSuggestedTaskItems, + runSmartCardActionWithOllama, + suggestCardTasksWithOllama, +} = require('../lib/aiTaskSuggestions'); + +async function run() { + assert.strictEqual(normalizeOllamaBaseUrl('localhost:11434/'), 'http://localhost:11434'); + assert.strictEqual(buildOllamaChatUrl('http://127.0.0.1:11434'), 'http://127.0.0.1:11434/api/chat'); + assert.strictEqual(buildOllamaTagsUrl('http://127.0.0.1:11434'), 'http://127.0.0.1:11434/api/tags'); + + assert.deepStrictEqual(normalizeOllamaModelList([ + { name: 'llama3.2:latest', size: 123 }, + { model: 'qwen2.5:7b', details: { parameter_size: '7.6B' } }, + { name: 'llama3.2:latest' }, + {}, + ]).map((model) => model.name), [ + 'llama3.2:latest', + 'qwen2.5:7b', + ]); + + assert.deepStrictEqual(normalizeSuggestedTaskItems([ + '- [ ] Confirm venue', + '1. Confirm venue', + '* Draft invitation', + '', + ], { maxTasks: 6 }), [ + 'Confirm venue', + 'Draft invitation', + ]); + + assert.deepStrictEqual(extractExistingChecklistItems(` +- [ ] Book room +- [x] Send email +Not a task + `), [ + 'Book room', + 'Send email', + ]); + + assert.deepStrictEqual(extractSuggestedTasksFromContent(JSON.stringify([ + { title: 'Confirm guest count' }, + { task: '- [ ] Order refreshments' }, + ]), { maxTasks: 6 }), [ + 'Confirm guest count', + 'Order refreshments', + ]); + + assert.deepStrictEqual(extractSuggestedTasksFromContent(JSON.stringify({ + tasks: [], + task_list: [ + { text: 'Draft agenda' }, + { action: 'Reserve meeting room' }, + ], + }), { maxTasks: 6 }), [ + 'Draft agenda', + 'Reserve meeting room', + ]); + + assert.deepStrictEqual(extractSuggestedTasksFromContent(` +Here are useful tasks: +- [ ] Confirm venue +1. Send invitations + `, { maxTasks: 6 }), [ + 'Confirm venue', + 'Send invitations', + ]); + assert.strictEqual(extractSuggestedTitleFromContent('{"title":"Plan camping meals"}'), 'Plan camping meals'); + assert.strictEqual(extractSuggestedMarkdownBodyFromContent('{"body":"## Summary\\n\\nKeep the full details."}'), '## Summary\n\nKeep the full details.'); + + let capturedRequest = null; + const result = await suggestCardTasksWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 4, + }, { + title: 'Plan launch party', + body: '- [ ] Book room', + boardName: 'Marketing', + listName: 'To-do', + labels: ['Event'], + }, { + currentDate: '2026-06-16', + fetchImpl: async (url, request) => { + capturedRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + message: { + content: JSON.stringify({ + items: [ + 'Confirm guest count', + '- [ ] Order refreshments', + 'Confirm guest count', + ], + }), + }, + }), + }; + }, + }); + + assert.strictEqual(capturedRequest.url, 'http://127.0.0.1:11434/api/chat'); + const body = JSON.parse(capturedRequest.request.body); + assert.strictEqual(body.model, 'llama3.2'); + assert.strictEqual(body.stream, false); + assert.strictEqual(body.think, false); + assert.strictEqual(body.format, 'json'); + assert.strictEqual(body.options.num_predict, DEFAULT_TASK_NUM_PREDICT); + assert.deepStrictEqual(result.tasks, [ + 'Confirm guest count', + 'Order refreshments', + ]); + assert.strictEqual(result.provider, 'ollama'); + + let emptySuggestionError = null; + try { + await suggestCardTasksWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'tiny-model', + taskCount: 4, + }, { + title: 'Plan launch party', + }, { + fetchImpl: async () => ({ + ok: true, + status: 200, + json: async () => ({ + model: 'tiny-model', + message: { + role: 'assistant', + content: JSON.stringify({ tasks: [] }), + }, + done: true, + }), + }), + }); + } catch (error) { + emptySuggestionError = error; + } + + assert(emptySuggestionError, 'Expected empty suggestions to throw.'); + assert.strictEqual(emptySuggestionError.code, 'AI_EMPTY_SUGGESTIONS'); + assert.strictEqual(emptySuggestionError.details.model, 'tiny-model'); + assert.strictEqual(emptySuggestionError.details.endpoint, 'http://127.0.0.1:11434/api/chat'); + assert.strictEqual(emptySuggestionError.details.responseContent, JSON.stringify({ tasks: [] })); + assert.deepStrictEqual(emptySuggestionError.details.response.message, { + role: 'assistant', + content: JSON.stringify({ tasks: [] }), + }); + + let thinkingOnlyError = null; + try { + await suggestCardTasksWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'thinking-model', + taskCount: 4, + }, { + title: 'Shopping list for 3 day camping trip', + }, { + fetchImpl: async () => ({ + ok: true, + status: 200, + json: async () => ({ + model: 'thinking-model', + message: { + role: 'assistant', + content: '', + thinking: 'Drafting tasks but never reaching the final JSON response.', + }, + done: true, + done_reason: 'length', + }), + }), + }); + } catch (error) { + thinkingOnlyError = error; + } + + assert(thinkingOnlyError, 'Expected thinking-only response to throw.'); + assert.strictEqual(thinkingOnlyError.code, 'AI_THINKING_RESPONSE_TRUNCATED'); + assert.strictEqual(thinkingOnlyError.details.doneReason, 'length'); + assert.strictEqual(thinkingOnlyError.details.responseHasThinking, true); + assert.strictEqual(thinkingOnlyError.details.request.think, false); + assert.strictEqual(thinkingOnlyError.details.request.numPredict, DEFAULT_TASK_NUM_PREDICT); + + let capturedTitleActionRequest = null; + const titleActionResult = await runSmartCardActionWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 4, + }, { + id: 'generate-title', + type: 'title', + label: 'Generate new title', + prompt: 'Improve the title.', + builtIn: true, + }, { + title: 'stuff', + body: 'Need shopping list for a 3 day camping trip.', + }, { + fetchImpl: async (url, request) => { + capturedTitleActionRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + message: { + content: JSON.stringify({ title: 'Plan shopping for 3-day camping trip' }), + }, + }), + }; + }, + }); + const titleActionBody = JSON.parse(capturedTitleActionRequest.request.body); + assert.strictEqual(titleActionBody.think, false); + assert.strictEqual(titleActionBody.options.num_predict, DEFAULT_TITLE_NUM_PREDICT); + assert.strictEqual(titleActionResult.actionType, 'title'); + assert.strictEqual(titleActionResult.title, 'Plan shopping for 3-day camping trip'); + + let capturedTaskActionRequest = null; + const taskActionResult = await runSmartCardActionWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 4, + }, { + id: 'generate-task-list', + type: 'tasks', + label: 'Generate task list', + prompt: 'Generate tasks.', + builtIn: true, + }, { + title: 'Plan launch party', + }, { + fetchImpl: async (url, request) => { + capturedTaskActionRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + message: { + content: JSON.stringify({ tasks: ['Book room', 'Invite team'] }), + }, + }), + }; + }, + }); + const taskActionBody = JSON.parse(capturedTaskActionRequest.request.body); + assert.strictEqual(taskActionBody.options.num_predict, DEFAULT_TASK_NUM_PREDICT); + assert.deepStrictEqual(taskActionResult.tasks, ['Book room', 'Invite team']); + + let capturedPasteActionRequest = null; + const pasteActionResult = await runSmartCardActionWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + }, { + id: 'smart-paste', + type: 'paste', + label: 'Smart paste', + prompt: 'Format pasted text.', + builtIn: true, + }, { + title: 'Client request', + }, { + pasteText: 'Client asked for a Friday delivery. https://example.com', + fetchImpl: async (url, request) => { + capturedPasteActionRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + message: { + content: JSON.stringify({ body: '## Summary\n\nClient asked for Friday delivery.\n\n## References\n\n- https://example.com' }), + }, + }), + }; + }, + }); + const pasteActionBody = JSON.parse(capturedPasteActionRequest.request.body); + assert.strictEqual(pasteActionBody.options.num_predict, DEFAULT_SMART_BODY_NUM_PREDICT); + assert(pasteActionBody.messages[1].content.includes('Client asked for a Friday delivery.')); + assert.strictEqual(pasteActionResult.actionType, 'paste'); + assert(pasteActionResult.body.includes('Client asked for Friday delivery.')); + + let capturedTagsRequest = null; + const modelList = await listOllamaModels({ + url: 'http://127.0.0.1:11434', + }, { + fetchImpl: async (url, request) => { + capturedTagsRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + models: [ + { name: 'llama3.2:latest', details: { parameter_size: '3.2B' } }, + { model: 'qwen2.5:7b' }, + ], + }), + }; + }, + }); + + assert.strictEqual(capturedTagsRequest.url, 'http://127.0.0.1:11434/api/tags'); + assert.strictEqual(capturedTagsRequest.request.method, 'GET'); + assert.deepStrictEqual(modelList.models.map((model) => model.name), [ + 'llama3.2:latest', + 'qwen2.5:7b', + ]); + + console.log('AI task suggestion tests passed.'); +} + +run().catch((error) => { + console.error('AI task suggestion tests failed.'); + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/test-app-settings.js b/scripts/test-app-settings.js index 17773ee..ea74154 100644 --- a/scripts/test-app-settings.js +++ b/scripts/test-app-settings.js @@ -4,11 +4,27 @@ const os = require('os'); const path = require('path'); const { + DEFAULT_SMART_CARD_ACTIONS, readAppSettings, updateAppSettings, migrateAppSettingsFromBoardSettings, } = require('../lib/appSettings'); +function assertDefaultSmartCardActions(actions) { + assert.deepStrictEqual(actions.map((action) => ({ + id: action.id, + type: action.type, + label: action.label, + builtIn: action.builtIn, + })), DEFAULT_SMART_CARD_ACTIONS().map((action) => ({ + id: action.id, + type: action.type, + label: action.label, + builtIn: action.builtIn, + }))); + assert(actions.every((action) => typeof action.prompt === 'string' && action.prompt.length > 0)); +} + async function run() { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-app-settings-')); @@ -22,6 +38,17 @@ async function run() { port: 48273, token: '', }); + assert.deepStrictEqual(defaults.ai, { + enabled: false, + provider: 'ollama', + ollama: { + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 6, + }, + smartCardActions: defaults.ai.smartCardActions, + }); + assertDefaultSmartCardActions(defaults.ai.smartCardActions); assert.strictEqual(defaults.migration.boardSettingsMigrated, false); const updated = await updateAppSettings(tmpDir, { @@ -33,6 +60,33 @@ async function run() { port: '49152', token: 'calendar-token_123', }, + ai: { + enabled: true, + provider: 'unknown', + ollama: { + url: 'localhost:11434/', + model: ' qwen2.5:7b ', + taskCount: '8', + }, + smartCardActions: [ + { + id: 'generate-title', + prompt: 'Custom title prompt', + }, + { + id: 'custom-follow-up', + type: 'custom', + label: ' Draft follow up ', + prompt: ' Draft a follow-up section. ', + }, + { + id: 'bad-custom', + type: 'custom', + label: '', + prompt: '', + }, + ], + }, }); assert.deepStrictEqual(updated.notifications, { enabled: true, time: '08:30' }); assert.strictEqual(updated.tooltipsEnabled, false); @@ -42,6 +96,28 @@ async function run() { port: 49152, token: 'calendar-token_123', }); + assert.deepStrictEqual(updated.ai, { + enabled: true, + provider: 'ollama', + ollama: { + url: 'http://localhost:11434', + model: 'qwen2.5:7b', + taskCount: 8, + }, + smartCardActions: updated.ai.smartCardActions, + }); + assert.strictEqual(updated.ai.smartCardActions.length, 4); + assert.strictEqual(updated.ai.smartCardActions[0].id, 'generate-title'); + assert.strictEqual(updated.ai.smartCardActions[0].prompt, 'Custom title prompt'); + assert.strictEqual(updated.ai.smartCardActions[1].id, 'generate-task-list'); + assert.strictEqual(updated.ai.smartCardActions[2].id, 'smart-paste'); + assert.deepStrictEqual(updated.ai.smartCardActions[3], { + id: 'custom-follow-up', + type: 'custom', + label: 'Draft follow up', + prompt: 'Draft a follow-up section.', + builtIn: false, + }); const secondTmpDir = path.join(tmpDir, 'migration'); await fs.mkdir(secondTmpDir); @@ -58,6 +134,17 @@ async function run() { port: 48273, token: '', }); + assert.deepStrictEqual(migrated.settings.ai, { + enabled: false, + provider: 'ollama', + ollama: { + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + taskCount: 6, + }, + smartCardActions: migrated.settings.ai.smartCardActions, + }); + assertDefaultSmartCardActions(migrated.settings.ai.smartCardActions); assert.strictEqual(migrated.settings.migration.boardSettingsMigrated, true); assert.strictEqual(migrated.settings.migration.sourceBoardRoot, '/tmp/first-board'); diff --git a/scripts/test-cli.js b/scripts/test-cli.js index 6765f27..373a0eb 100644 --- a/scripts/test-cli.js +++ b/scripts/test-cli.js @@ -269,8 +269,10 @@ async function main() { const fixture = await createFixtureBoard(); const importFixtures = await createImportFixtures(fixture.root); const configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-cli-config-')); + const desktopUserDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-desktop-user-data-')); const env = { SIGNBOARD_CLI_CONFIG_DIR: configDir, + SIGNBOARD_DESKTOP_USER_DATA_DIR: desktopUserDataDir, }; const createdBoardRoot = path.join(fixture.root, 'Created CLI Board'); @@ -314,6 +316,38 @@ async function main() { const currentBoard = runCli(['use'], env); assert.strictEqual(currentBoard.stdout.trim(), fixture.boardRoot); + await fs.writeFile( + path.join(desktopUserDataDir, 'trusted-board-roots.json'), + JSON.stringify([createdBoardRoot, fixture.boardRoot], null, 2), + 'utf8', + ); + await fs.writeFile( + path.join(desktopUserDataDir, 'open-boards.json'), + JSON.stringify({ + openBoardRoots: [createdBoardRoot, fixture.boardRoot], + activeBoardRoot: createdBoardRoot, + }, null, 2), + 'utf8', + ); + + const boardsList = JSON.parse(runCli(['boards', 'list', '--json'], env).stdout); + assert.strictEqual(boardsList.activeBoard, createdBoardRoot); + assert.strictEqual(boardsList.currentBoard, fixture.boardRoot); + const activeBoard = boardsList.boards.find((board) => board.boardRoot === createdBoardRoot); + assert.ok(activeBoard); + assert.strictEqual(activeBoard.isActive, true); + assert.strictEqual(activeBoard.isOpen, true); + assert.strictEqual(activeBoard.isTrusted, true); + assert.strictEqual(activeBoard.isAllowed, true); + assert.ok(activeBoard.sources.includes('desktop-open')); + const cliCurrentBoard = boardsList.boards.find((board) => board.boardRoot === fixture.boardRoot); + assert.ok(cliCurrentBoard); + assert.strictEqual(cliCurrentBoard.isCurrent, true); + assert.ok(cliCurrentBoard.sources.includes('cli-current')); + + const boardsDefaultList = JSON.parse(runCli(['boards', '--json'], env).stdout); + assert.strictEqual(boardsDefaultList.boardCount, boardsList.boardCount); + const invalidSingular = runCliExpectFail(['card', '--read', 'kqmf2'], env); assert.ok(invalidSingular.stderr.includes('Unknown command group: card')); assert.ok(invalidSingular.stderr.includes('Did you mean `cards`?')); diff --git a/scripts/test-mcp-server.js b/scripts/test-mcp-server.js index cb9d79b..733207f 100644 --- a/scripts/test-mcp-server.js +++ b/scripts/test-mcp-server.js @@ -347,6 +347,7 @@ async function runForTransport(transportMode, fixture) { const requiredToolNames = [ 'signboard_get_config', + 'signboard_list_boards', 'signboard_list_board_views', 'signboard_resolve_board_by_name', 'signboard_create_board', @@ -398,6 +399,32 @@ async function runForTransport(transportMode, fixture) { throw new Error(`Legacy tool alias returned unexpected payload (${transportMode}): ${JSON.stringify(legacyAliasResponse.result)}`); } + send({ + jsonrpc: '2.0', + id: 251, + method: 'tools/call', + params: { + name: 'signboard_list_boards', + arguments: {}, + }, + }); + + const listBoardsResponse = await waitForResponse(251); + if (listBoardsResponse.error) { + throw new Error(`list_boards failed (${transportMode}): ${JSON.stringify(listBoardsResponse.error)}`); + } + + const listBoardsOutput = listBoardsResponse.result?.structuredContent || {}; + const discoveredBoard = Array.isArray(listBoardsOutput.boards) + ? listBoardsOutput.boards.find((board) => board.boardRoot === path.resolve(fixture.boardRoot)) + : null; + if (!discoveredBoard || !discoveredBoard.isAllowed || !discoveredBoard.isBoardRoot) { + throw new Error(`list_boards did not discover fixture board (${transportMode}): ${JSON.stringify(listBoardsOutput)}`); + } + if (!Array.isArray(discoveredBoard.sources) || !discoveredBoard.sources.includes('mcp-allowed-root-scan')) { + throw new Error(`list_boards fixture board sources mismatch (${transportMode}): ${JSON.stringify(discoveredBoard)}`); + } + send({ jsonrpc: '2.0', id: 3, @@ -1274,7 +1301,8 @@ async function runTrustedRootsSmoke() { [ "const { startSignboardMcpServer } = require('./lib/mcpServer');", "const trustedBoardRoots = JSON.parse(process.env.SIGNBOARD_TEST_TRUSTED_ROOTS || '[]');", - "startSignboardMcpServer({ appVersion: 'test', trustedBoardRoots });", + "const desktopOpenBoardsState = JSON.parse(process.env.SIGNBOARD_TEST_OPEN_BOARDS || '{}');", + "startSignboardMcpServer({ appVersion: 'test', trustedBoardRoots, desktopOpenBoardsState });", ].join(' '), ], { @@ -1284,6 +1312,10 @@ async function runTrustedRootsSmoke() { SIGNBOARD_MCP_ALLOWED_ROOTS: path.join(fixture.cleanupRoot, 'unrelated-root'), SIGNBOARD_MCP_READ_ONLY: 'true', SIGNBOARD_TEST_TRUSTED_ROOTS: JSON.stringify([fixture.boardRoot]), + SIGNBOARD_TEST_OPEN_BOARDS: JSON.stringify({ + openBoardRoots: [fixture.boardRoot], + activeBoardRoot: fixture.boardRoot, + }), }, stdio: ['pipe', 'pipe', 'pipe'], }, @@ -1387,6 +1419,28 @@ async function runTrustedRootsSmoke() { if (!Array.isArray(config.allowedRoots) || !config.allowedRoots.includes(path.resolve(fixture.boardRoot))) { throw new Error(`Trusted board root missing from get_config: ${JSON.stringify(config)}`); } + if (!Array.isArray(config.openBoardRoots) || !config.openBoardRoots.includes(path.resolve(fixture.boardRoot))) { + throw new Error(`Open board root missing from get_config: ${JSON.stringify(config)}`); + } + + send({ + jsonrpc: '2.0', + id: 25, + method: 'tools/call', + params: { + name: 'signboard_list_boards', + arguments: {}, + }, + }); + + const listBoardsResponse = await waitForResponse(25); + const listBoardsOutput = listBoardsResponse.result?.structuredContent || {}; + const openBoard = Array.isArray(listBoardsOutput.boards) + ? listBoardsOutput.boards.find((board) => board.boardRoot === path.resolve(fixture.boardRoot)) + : null; + if (!openBoard || !openBoard.isOpen || !openBoard.isActive || !openBoard.isTrusted) { + throw new Error(`Trusted/open board metadata missing from list_boards: ${JSON.stringify(listBoardsOutput)}`); + } send({ jsonrpc: '2.0', diff --git a/skills/signboard-mcp/SKILL.md b/skills/signboard-mcp/SKILL.md index 3326aab..a20f7c0 100644 --- a/skills/signboard-mcp/SKILL.md +++ b/skills/signboard-mcp/SKILL.md @@ -18,16 +18,17 @@ Use this skill when the user asks to read or modify Signboard data through MCP. ## Tool Workflow 1. Call `signboard_get_config` first. -2. If board root is unknown, prefer `signboard_resolve_board_by_name` first when `allowedRoots` are available; otherwise ask user for the absolute board path. -3. Discover structure: +2. Call `signboard_list_boards` when board root is unknown or ambiguous; prefer an `isActive` or `isOpen` match when it fits the user's request. +3. If the board is not listed but the name is known, use `signboard_resolve_board_by_name` when `allowedRoots` are available; otherwise ask user for the absolute board path. +4. Discover structure: - `signboard_list_lists` - `signboard_list_cards` - `signboard_read_card` as needed -4. Before write actions, verify: +5. Before write actions, verify: - user requested the change - server is not read-only - target list/card exists (or should be created) -5. Execute write tool only after checks: +6. Execute write tool only after checks: - `signboard_create_card` - `signboard_update_card` - `signboard_duplicate_card` @@ -49,7 +50,8 @@ Use this skill when the user asks to read or modify Signboard data through MCP. ## Tool Reference - `signboard_get_config`: inspect MCP mode and path constraints. -- `signboard_list_board_views`: list available board views (`kanban`, `calendar`, `this-week`). +- `signboard_list_boards`: list known usable board roots with desktop-open, active, trusted, current, and allowed-root metadata. +- `signboard_list_board_views`: list available board views (`kanban`, `table`). - `signboard_resolve_board_by_name`: map a board directory name to absolute board paths under allowed roots, including allowed roots that are themselves board folders. - `signboard_list_lists`: get list directory names in a board. - `signboard_list_cards`: get card markdown files in a list. diff --git a/static/styles.css b/static/styles.css index 8f7b72e..156ab8d 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3727,6 +3727,12 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, margin-top: 12px; } +.boardSettingsSection > .board-settings-group + .board-settings-group { + border-top: 1px solid var(--border); + margin-top: 16px; + padding-top: 16px; +} + .board-settings-subheading { margin: 0 0 6px; font-size: var(--font-md); @@ -3788,6 +3794,12 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, min-width: 0; } +.board-settings-inline-actions select { + flex: 1 1 auto; + width: auto; + min-width: 0; +} + .board-settings-inline-actions-right { justify-content: flex-end; } @@ -3822,6 +3834,109 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, min-width: 10ch; } +#boardSettingsAiDetails { + display: grid; + grid-template-columns: minmax(120px, 160px) minmax(0, 1fr); + gap: 8px 12px; + align-items: center; +} + +#boardSettingsAiDetails.hidden { + display: none; +} + +#boardSettingsAiDetails .boardSettingsHint { + grid-column: 1 / -1; +} + +#boardSettingsAiDetails .board-settings-ai-model-actions { + min-width: 0; +} + +#boardSettingsAiDetails .board-settings-ai-actions-header, +#boardSettingsAiDetails .board-settings-ai-actions-list { + grid-column: 1 / -1; +} + +.board-settings-ai-actions-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} + +.board-settings-ai-actions-header button { + flex: 0 0 auto; +} + +.board-settings-ai-actions-list { + display: grid; + gap: 10px; +} + +.board-settings-ai-action { + display: grid; + gap: 7px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in oklab, var(--bg-card) 96%, var(--border)); + padding: 10px; +} + +.board-settings-ai-action-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.board-settings-ai-action-title, +.board-settings-ai-action-label { + margin: 0; + color: var(--text); + font-size: var(--font-sm); + font-weight: 700; + line-height: 1.25; +} + +.board-settings-ai-action input, +.board-settings-ai-action textarea { + width: 100%; +} + +.board-settings-ai-action textarea { + min-height: 92px; + resize: vertical; +} + +.board-settings-ai-action-reset, +.board-settings-ai-action-remove { + min-height: 30px; + padding: 5px 10px; +} + +#btnRefreshAiOllamaModels { + flex: 0 0 auto; + width: 36px; + min-width: 36px; + height: 36px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +#btnRefreshAiOllamaModels svg { + width: 16px; + height: 16px; +} + +#boardSettingsAiTaskCount { + width: 8ch; + min-width: 8ch; +} + .board-settings-toggle-row { border: 1px solid var(--border); border-radius: var(--radius); @@ -4607,7 +4722,9 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, #modalEditCard .cardEditorHeaderActions a + a, #modalEditCard .cardEditorHeaderActions button + button { margin-left: 20px; } .card-editor-open-with-popover, -.card-editor-linked-objects-popover { +.card-editor-linked-objects-popover, +.card-editor-ai-tasks-popover, +.card-editor-smart-actions-popover { position: fixed; min-width: 220px; width: max-content; @@ -4616,6 +4733,143 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, .card-editor-open-with-action { min-height: 36px; } +#modalEditCard .card-editor-smart-actions-button { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 8; + width: 44px; + height: 44px; + min-width: 44px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--accent) 70%, var(--border)); + background: var(--accent); + color: var(--accent-contrast); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + box-shadow: 0 10px 24px var(--shadow-card); +} +#modalEditCard .card-editor-smart-actions-button:hover:not(:disabled) { + background: color-mix(in oklab, var(--accent) 88%, var(--text)); +} +#modalEditCard .card-editor-smart-actions-button:active { + transform: translateY(1px); +} +#modalEditCard .card-editor-smart-actions-button[hidden] { + display: none; +} +#modalEditCard .card-editor-smart-actions-button svg { + width: 19px; + height: 19px; +} +.card-editor-ai-tasks-popover, +.card-editor-smart-actions-popover { + min-width: 300px; + max-width: min(420px, calc(100vw - 16px)); + padding: 10px; +} +.card-editor-smart-actions-popover { + opacity: 1; + transform: translateY(0); + transition: opacity 90ms ease, transform 90ms ease; +} +.card-editor-smart-actions-popover.hidden { + opacity: 0; + transform: translateY(4px); +} +.card-editor-ai-tasks-title { + margin: 0 0 8px; + color: var(--text); + font-size: var(--font-sm); + font-weight: 700; + line-height: 1.2; +} +.card-editor-ai-tasks-status { + margin: 0; + color: var(--muted); + font-size: var(--font-sm); + line-height: 1.35; +} +.card-editor-ai-tasks-status.is-error { + color: var(--due-date-today-color); +} +.card-editor-ai-tasks-list { + margin: 0; + padding: 0 0 0 18px; + color: var(--text); + font-size: var(--font-sm); + line-height: 1.35; +} +.card-editor-ai-tasks-list li + li { + margin-top: 6px; +} +.card-editor-ai-tasks-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} +.card-editor-ai-tasks-actions button { + min-height: 32px; + padding: 6px 11px; +} +.card-editor-ai-tasks-primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-contrast); +} +.card-editor-ai-tasks-primary:hover:not(:disabled) { + background: color-mix(in oklab, var(--accent) 88%, var(--text)); +} +.card-editor-ai-tasks-secondary { + border-color: var(--border); + background: var(--bg-card); + color: var(--text); +} +.card-editor-ai-tasks-secondary:hover:not(:disabled) { + background: color-mix(in oklab, var(--bg-card) 88%, var(--border)); +} +.card-editor-smart-actions-menu { + display: grid; + gap: 4px; +} +.card-editor-smart-action-button { + width: 100%; + min-height: 38px; +} +.card-editor-smart-paste-input { + width: 100%; + min-height: 180px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + font: inherit; + font-size: var(--font-sm); + line-height: 1.4; + padding: 8px 9px; + resize: vertical; +} +.card-editor-smart-action-preview { + max-height: min(360px, calc(100vh - 220px)); + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in oklab, var(--bg-card) 94%, var(--bg)); + color: var(--text); + font-size: var(--font-sm); + line-height: 1.45; + white-space: pre-wrap; + padding: 9px; +} + +@media (prefers-reduced-motion: reduce) { + .card-editor-smart-actions-popover { + transition: none; + } +} .card-editor-linked-object-url-form { display: flex; flex-direction: column; diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index 7bdf9a6..2337b73 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -1,4 +1,5 @@ const fs = require('fs').promises; +const http = require('http'); const net = require('net'); const os = require('os'); const path = require('path'); @@ -71,6 +72,34 @@ async function getAvailableLoopbackPort() { }); } +async function startFakeOllamaServer(models) { + const server = http.createServer((request, response) => { + if (request.method === 'GET' && request.url === '/api/tags') { + response.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ models })); + return; + } + + response.writeHead(404, { + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ error: 'not found' })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + async function openCurrentBoardPlannerView(page, shortcut, viewSelector) { await page.keyboard.press(getShortcut(shortcut)); await expect(page.locator('#plannerOverlay')).toBeVisible(); @@ -1216,6 +1245,12 @@ test('navigates board popovers and settings sections from the keyboard', async ( await expect(page.locator('#modalBoardSettings')).toBeVisible(); await expect(page.locator('#boardSettingsNavApp')).toBeFocused(); await page.keyboard.press('ArrowDown'); + await expect(page.locator('#boardSettingsNavNotifications')).toBeFocused(); + await expect(page.locator('#boardSettingsPanelNotifications')).toHaveAttribute('aria-hidden', 'false'); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('#boardSettingsNavSmartActions')).toBeFocused(); + await expect(page.locator('#boardSettingsPanelSmartActions')).toHaveAttribute('aria-hidden', 'false'); + await page.keyboard.press('ArrowDown'); await expect(page.locator('#boardSettingsNavGeneral')).toBeFocused(); await expect(page.locator('#boardSettingsPanelGeneral')).toHaveAttribute('aria-hidden', 'false'); await page.keyboard.press('End'); @@ -2359,6 +2394,8 @@ test('opens settings from the renderer keyboard shortcut', async ({ page }) => { expect(switchLabelStyles.marginBottom).toBe('0px'); expect(switchLabelStyles.textTransform).toBe('none'); + await page.locator('#boardSettingsNavNotifications').click(); + await expect(page.locator('#boardSettingsPanelNotifications')).toBeVisible(); const notificationsDetails = page.locator('#boardSettingsNotificationsDetails'); await expect(notificationsDetails).toBeHidden(); await expect(notificationsDetails).toHaveAttribute('aria-hidden', 'true'); @@ -2709,6 +2746,131 @@ test('persists the global quick add shortcut setting', async ({ page }) => { await expect(shortcutInput).toHaveValue('CommandOrControl+Shift+Space'); }); +test('persists AI assistance settings and shows the card editor Smart Card Actions menu', async ({ page }) => { + const fakeOllama = await startFakeOllamaServer([ + { + name: 'llama3.2:latest', + model: 'llama3.2:latest', + details: { parameter_size: '3.2B' }, + }, + { + name: 'qwen2.5:7b', + model: 'qwen2.5:7b', + details: { parameter_size: '7.6B' }, + }, + ]); + + try { + await page.evaluate(async (ollamaUrl) => { + const current = await window.electronAPI.readAppSettings(); + await window.electronAPI.updateAppSettings({ + ai: { + ...current.ai, + enabled: false, + ollama: { + ...current.ai.ollama, + url: ollamaUrl, + model: 'llama3.2', + taskCount: 6, + }, + }, + }); + if (typeof loadAppSettings === 'function') { + await loadAppSettings(); + } + }, fakeOllama.url); + + await openBoardMenu(page); + await page.locator('#openBoardSettings').click(); + await expect(page.locator('#modalBoardSettings')).toBeVisible(); + await page.locator('#boardSettingsNavSmartActions').click(); + await expect(page.locator('#boardSettingsPanelSmartActions')).toBeVisible(); + + const aiToggle = page.locator('#boardSettingsAiToggle'); + const aiDetails = page.locator('#boardSettingsAiDetails'); + const aiUrlInput = page.locator('#boardSettingsAiOllamaUrl'); + const aiModelSelect = page.locator('#boardSettingsAiOllamaModel'); + const aiRefreshButton = page.locator('#btnRefreshAiOllamaModels'); + const aiStatus = page.locator('#boardSettingsAiOllamaStatus'); + const aiTaskCountInput = page.locator('#boardSettingsAiTaskCount'); + + await expect(aiToggle).not.toBeChecked(); + await expect(aiDetails).toBeHidden(); + await expect(aiDetails).toHaveAttribute('aria-hidden', 'true'); + + await page.locator('label[for="boardSettingsAiToggle"]').click(); + await expect(aiToggle).toBeChecked(); + await expect(aiDetails).toBeVisible(); + await expect(aiDetails).toHaveAttribute('aria-hidden', 'false'); + await expect(aiUrlInput).toHaveValue(fakeOllama.url); + await expect(aiStatus).toContainText(/Connected/); + await expect(aiModelSelect).toContainText('qwen2.5:7b'); + await expect(aiModelSelect).toContainText('llama3.2:latest'); + + await aiModelSelect.selectOption('qwen2.5:7b'); + await aiTaskCountInput.fill('8'); + await aiTaskCountInput.blur(); + await aiRefreshButton.click(); + await expect(aiStatus).toContainText(/Connected/); + + await expect.poll(async () => { + return await page.evaluate(async () => { + const settings = await window.electronAPI.readAppSettings(); + return { + enabled: settings.ai.enabled, + provider: settings.ai.provider, + ollama: settings.ai.ollama, + actionLabels: settings.ai.smartCardActions.map((action) => action.label), + }; + }); + }).toEqual({ + enabled: true, + provider: 'ollama', + ollama: { + url: fakeOllama.url, + model: 'qwen2.5:7b', + taskCount: 8, + }, + actionLabels: [ + 'Generate new title', + 'Generate task list', + 'Smart paste', + ], + }); + + await page.locator('#boardSettingsClose').click(); + await expect(page.locator('#modalBoardSettings')).toBeHidden(); + + await openFirstCardInEditor(page); + const aiButton = page.locator('#cardEditorSmartActionsButton'); + await expect(aiButton).toBeVisible(); + await expect(aiButton).toHaveAttribute('aria-label', 'Smart Card Actions with qwen2.5:7b'); + await aiButton.click(); + const smartActionsPopover = page.locator('#cardEditorSmartActionsPopover'); + await expect(smartActionsPopover).toBeVisible(); + await expect.poll(async () => { + return await page.evaluate(() => { + const modal = document.getElementById('modalEditCard'); + const popover = document.getElementById('cardEditorSmartActionsPopover'); + return { + modalZIndex: Number.parseInt(window.getComputedStyle(modal).zIndex || '0', 10), + popoverZIndex: Number.parseInt(window.getComputedStyle(popover).zIndex || '0', 10), + popoverHidden: popover.classList.contains('hidden'), + }; + }); + }).toEqual({ + modalZIndex: 50, + popoverZIndex: 12020, + popoverHidden: false, + }); + await expect(smartActionsPopover).toContainText('Generate new title'); + await expect(smartActionsPopover).toContainText('Generate task list'); + await expect(smartActionsPopover).toContainText('Smart paste'); + } finally { + await fakeOllama.close(); + } +}); + test('publishes the External Published Calendar and respects board opt-out', async ({ page, boardRoot, request }) => { const calendarPort = await getAvailableLoopbackPort(); await cardFrontmatter.updateFrontmatter( @@ -2719,6 +2881,8 @@ test('publishes the External Published Calendar and respects board opt-out', asy await openBoardMenu(page); await page.locator('#openBoardSettings').click(); await expect(page.locator('#modalBoardSettings')).toBeVisible(); + await page.locator('#boardSettingsNavNotifications').click(); + await expect(page.locator('#boardSettingsPanelNotifications')).toBeVisible(); const calendarToggle = page.locator('#boardSettingsExternalCalendarToggle'); const calendarStatus = page.locator('#boardSettingsExternalCalendarStatus'); From 0daf194d62347e996736e2ec25eab31e7254023a Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Mon, 22 Jun 2026 21:27:16 -0400 Subject: [PATCH 04/10] Added bulk editing in table view --- AGENTS.md | 2 +- CODEX.md | 2 +- app/board/tableView.js | 764 ++++++++++++++++++++++- docs/README.md | 2 +- docs/codex/FILE_STRUCTURE.md | 2 +- docs/codex/PROJECT_CONTEXT.md | 5 +- docs/using-signboard.md | 8 +- readme.md | 2 +- scripts/test-board-views.js | 51 +- static/styles.css | 236 ++++++- tests/playwright/signboard-smoke.spec.js | 108 ++++ 11 files changed, 1156 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e3c424..eea9b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - MCP allowed roots include both explicit MCP roots and desktop trusted board roots; keep root loading, `get_config`, `signboard_list_boards`, and board-name resolution tests aligned. - CLI and MCP board discovery/creation should stay aligned: `signboard boards list` and `signboard_list_boards` report the same known-board metadata shape where applicable, while `signboard boards create` and `signboard_create_board` scaffold the same default lists and starter card. - Quick board switching uses `Cmd/Ctrl + K`, searches all currently open boards, and should keep tab switching, overflow tab switching, and switcher switching on the same safe board-switch helper. -- Planner is the workspace-level home for Calendar, This Week, Day, and Agenda, including all-open-board and current-board shortcut scopes; keep `app/board/plannerView.js`, Kanban/Table board rendering, search/filter behavior, and shortcut docs aligned. +- Planner is the workspace-level home for Calendar, This Week, Day, and Agenda, including all-open-board and current-board shortcut scopes; keep `app/board/plannerView.js`, Kanban/Table board rendering, Table bulk-selection behavior, search/filter behavior, and shortcut docs aligned. - Workspace-level keyboard shortcuts close the active card editor before changing context; editor-scoped card move/archive shortcuts should keep acting on the open card. - External board-change sync should refresh board cards and unchanged open card editors after MCP/card-file edits without overwriting local editor changes. - Card timestamp UI and automation surfaces use `timestamps.createdAt` and `timestamps.updatedAt`; keep editor timestamp display, Table age columns/sorting, CLI age sorts, and MCP card responses aligned with the shared timestamp helpers. diff --git a/CODEX.md b/CODEX.md index 1aecd6b..e84a06f 100644 --- a/CODEX.md +++ b/CODEX.md @@ -25,7 +25,7 @@ Start here before opening source files. - `preload.js` is now a thin IPC bridge only; board filesystem access, trusted-board validation, and filesystem watch helpers live in `main.js`, while `app/init.js` still uses the same watch methods to auto-refresh after external board changes and refresh an unchanged open card editor after external/MCP card edits. - Archive browsing and restore now run through `lib/archive.js` via `main.js` / `preload.js`; the renderer opens a dedicated Archive modal (`app/board/archiveBrowser.js`) instead of treating Archive as a board view. - Active-card adjacent-list moves use the main-process `moveCardToTop` IPC path backed by `lib/cardOrdering.js`, so renderer shortcuts do not perform multi-step filesystem renames directly. -- Board rendering supports Kanban by default plus a board-scoped Table view for scanning cards; Calendar, This Week, Day, and Agenda dated workflows live in Planner. +- Board rendering supports Kanban by default plus a board-scoped Table view for scanning and bulk-managing cards; Calendar, This Week, Day, and Agenda dated workflows live in Planner. - Planner is managed in `app/board/plannerView.js`; it appears as a left-edge overlay only when at least one board tab is open, defaults to all open boards, has a quick current-board scope toggle, and owns Calendar, This Week, Day, and Agenda views. - Planner search/filtering is separate from board search: it searches card title/body plus board/list source text, filters by `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days`, completed-card visibility, and selected open boards, and exposes label filtering only when scoped to the active board. - App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut, AI assistance/Ollama settings, and External Published Calendar settings are stored only in app settings. diff --git a/app/board/tableView.js b/app/board/tableView.js index c654e14..c72551e 100644 --- a/app/board/tableView.js +++ b/app/board/tableView.js @@ -1,4 +1,5 @@ const BOARD_TABLE_COLUMNS = Object.freeze([ + { id: 'select', label: '' }, { id: 'start', label: 'Start' }, { id: 'due', label: 'Due' }, { id: 'updated', label: 'Updated' }, @@ -20,6 +21,10 @@ const BOARD_TABLE_SORT_OPTIONS = Object.freeze([ { value: 'title-asc', label: 'Title, A-Z' }, ]); +const BOARD_TABLE_LIST_FILTER_ALL = 'all'; +const BOARD_TABLE_LIST_FILTER_COMPLETED = 'completed'; +const BOARD_TABLE_LIST_FILTER_PREFIX = 'list:'; + function normalizeBoardTableTitle(titleText) { const normalized = String(titleText || '').trim().replace(/^#\s+/, ''); return normalized || 'Untitled'; @@ -29,9 +34,27 @@ function getBoardTableState() { if (!window.__boardTableState) { window.__boardTableState = { sortKey: 'board', + listFilter: BOARD_TABLE_LIST_FILTER_ALL, + selectedCardPaths: new Set(), + lastSelectedCardPath: '', + activeBulkMenu: '', }; } + const state = window.__boardTableState; + if (!(state.selectedCardPaths instanceof Set)) { + state.selectedCardPaths = new Set(Array.isArray(state.selectedCardPaths) ? state.selectedCardPaths : []); + } + if (typeof state.listFilter !== 'string') { + state.listFilter = BOARD_TABLE_LIST_FILTER_ALL; + } + if (typeof state.lastSelectedCardPath !== 'string') { + state.lastSelectedCardPath = ''; + } + if (typeof state.activeBulkMenu !== 'string') { + state.activeBulkMenu = ''; + } + return window.__boardTableState; } @@ -41,6 +64,48 @@ function getBoardTableSortKey() { return BOARD_TABLE_SORT_OPTIONS.some((option) => option.value === sortKey) ? sortKey : 'board'; } +function normalizeBoardTableCardPath(cardPath) { + return String(cardPath || '').trim(); +} + +function getBoardTableSelectionSet() { + return getBoardTableState().selectedCardPaths; +} + +function clearBoardTableSelection() { + const state = getBoardTableState(); + state.selectedCardPaths.clear(); + state.lastSelectedCardPath = ''; + state.activeBulkMenu = ''; +} + +function getBoardTableListFilter(listEntries = []) { + const state = getBoardTableState(); + const listFilter = String(state.listFilter || BOARD_TABLE_LIST_FILTER_ALL); + const entries = Array.isArray(listEntries) ? listEntries : []; + + if (listFilter === BOARD_TABLE_LIST_FILTER_COMPLETED) { + return entries.some((entry) => entry && entry.isCompletedList) + ? listFilter + : BOARD_TABLE_LIST_FILTER_ALL; + } + + if (listFilter.startsWith(BOARD_TABLE_LIST_FILTER_PREFIX)) { + const listPath = listFilter.slice(BOARD_TABLE_LIST_FILTER_PREFIX.length); + return entries.some((entry) => entry && entry.listPath === listPath) + ? listFilter + : BOARD_TABLE_LIST_FILTER_ALL; + } + + return BOARD_TABLE_LIST_FILTER_ALL; +} + +function setBoardTableListFilter(listFilter) { + const state = getBoardTableState(); + state.listFilter = String(listFilter || BOARD_TABLE_LIST_FILTER_ALL); + clearBoardTableSelection(); +} + function getBoardTableListEntries(boardRoot, listsWithCards) { const entries = Array.isArray(listsWithCards) ? listsWithCards : []; @@ -100,6 +165,24 @@ function boardTableEntryMatchesFilters(entry) { }) && cardMatchesBoardSearch(entry.title, entry.body); } +function boardTableEntryMatchesListFilter(entry, listEntries) { + const listFilter = getBoardTableListFilter(listEntries); + if (listFilter === BOARD_TABLE_LIST_FILTER_ALL) { + return true; + } + + if (listFilter === BOARD_TABLE_LIST_FILTER_COMPLETED) { + return Boolean(entry && entry.isCompletedList); + } + + if (listFilter.startsWith(BOARD_TABLE_LIST_FILTER_PREFIX)) { + const listPath = listFilter.slice(BOARD_TABLE_LIST_FILTER_PREFIX.length); + return Boolean(entry && entry.listPath === listPath); + } + + return true; +} + async function collectBoardTableCards(boardRoot, listsWithCards) { const listEntries = getBoardTableListEntries(boardRoot, listsWithCards); const rowsByList = await Promise.all( @@ -151,13 +234,108 @@ async function collectBoardTableCards(boardRoot, listsWithCards) { ...entry, boardOrderIndex: index, })); + const cardsMatchingBoardFilters = allCards.filter(boardTableEntryMatchesFilters); return { listEntries, allCards, - visibleCards: allCards.filter(boardTableEntryMatchesFilters), + visibleCards: cardsMatchingBoardFilters.filter((entry) => boardTableEntryMatchesListFilter(entry, listEntries)), }; } +function pruneBoardTableSelection(visibleCards) { + const selection = getBoardTableSelectionSet(); + const visiblePaths = new Set((Array.isArray(visibleCards) ? visibleCards : []) + .map((entry) => normalizeBoardTableCardPath(entry && entry.cardPath)) + .filter(Boolean)); + + for (const cardPath of Array.from(selection)) { + if (!visiblePaths.has(cardPath)) { + selection.delete(cardPath); + } + } + + const state = getBoardTableState(); + if (state.lastSelectedCardPath && !visiblePaths.has(state.lastSelectedCardPath)) { + state.lastSelectedCardPath = ''; + } + + if (selection.size === 0) { + state.activeBulkMenu = ''; + } +} + +function isBoardTableEntrySelected(entry) { + return getBoardTableSelectionSet().has(normalizeBoardTableCardPath(entry && entry.cardPath)); +} + +function getBoardTableSelectedEntries(visibleEntries) { + const selection = getBoardTableSelectionSet(); + return (Array.isArray(visibleEntries) ? visibleEntries : []) + .filter((entry) => selection.has(normalizeBoardTableCardPath(entry && entry.cardPath))); +} + +function selectBoardTableVisibleEntries(visibleEntries, shouldSelect) { + const selection = getBoardTableSelectionSet(); + const entries = Array.isArray(visibleEntries) ? visibleEntries : []; + + for (const entry of entries) { + const cardPath = normalizeBoardTableCardPath(entry && entry.cardPath); + if (!cardPath) { + continue; + } + + if (shouldSelect) { + selection.add(cardPath); + } else { + selection.delete(cardPath); + } + } + + const state = getBoardTableState(); + state.lastSelectedCardPath = shouldSelect && entries.length > 0 + ? normalizeBoardTableCardPath(entries[0].cardPath) + : ''; + state.activeBulkMenu = ''; +} + +function selectBoardTableEntryRange(entry, visibleEntries, shouldSelect, useRange) { + const selection = getBoardTableSelectionSet(); + const state = getBoardTableState(); + const entries = Array.isArray(visibleEntries) ? visibleEntries : []; + const cardPath = normalizeBoardTableCardPath(entry && entry.cardPath); + if (!cardPath) { + return; + } + + const currentIndex = entries.findIndex((candidate) => normalizeBoardTableCardPath(candidate && candidate.cardPath) === cardPath); + const anchorIndex = entries.findIndex((candidate) => ( + normalizeBoardTableCardPath(candidate && candidate.cardPath) === state.lastSelectedCardPath + )); + + if (useRange && currentIndex >= 0 && anchorIndex >= 0) { + const startIndex = Math.min(currentIndex, anchorIndex); + const endIndex = Math.max(currentIndex, anchorIndex); + for (let index = startIndex; index <= endIndex; index += 1) { + const rangeCardPath = normalizeBoardTableCardPath(entries[index] && entries[index].cardPath); + if (!rangeCardPath) { + continue; + } + if (shouldSelect) { + selection.add(rangeCardPath); + } else { + selection.delete(rangeCardPath); + } + } + } else if (shouldSelect) { + selection.add(cardPath); + } else { + selection.delete(cardPath); + } + + state.lastSelectedCardPath = shouldSelect || selection.size > 0 ? cardPath : ''; + state.activeBulkMenu = ''; +} + function compareBoardTableBaseOrder(left, right) { const leftIndex = Number.isFinite(left && left.boardOrderIndex) ? left.boardOrderIndex : 0; const rightIndex = Number.isFinite(right && right.boardOrderIndex) ? right.boardOrderIndex : 0; @@ -299,7 +477,515 @@ function createBoardTableSortControl() { return control; } -function createBoardTableHeader() { +function createBoardTableListFilterControl(listEntries) { + const entries = Array.isArray(listEntries) ? listEntries : []; + const control = document.createElement('label'); + control.className = 'board-table-filter-control'; + + const labelText = document.createElement('span'); + labelText.className = 'board-table-filter-label'; + labelText.textContent = 'List'; + control.appendChild(labelText); + + const select = document.createElement('select'); + select.className = 'board-table-filter-select'; + select.setAttribute('aria-label', 'Filter table by list'); + + const allOption = document.createElement('option'); + allOption.value = BOARD_TABLE_LIST_FILTER_ALL; + allOption.textContent = 'All lists'; + select.appendChild(allOption); + + const hasCompletedLists = entries.some((entry) => entry && entry.isCompletedList); + const completedOption = document.createElement('option'); + completedOption.value = BOARD_TABLE_LIST_FILTER_COMPLETED; + completedOption.textContent = 'Completed lists'; + completedOption.disabled = !hasCompletedLists; + select.appendChild(completedOption); + + const listGroup = document.createElement('optgroup'); + listGroup.label = 'Lists'; + for (const entry of entries) { + if (!entry || !entry.listPath) { + continue; + } + const option = document.createElement('option'); + option.value = `${BOARD_TABLE_LIST_FILTER_PREFIX}${entry.listPath}`; + option.textContent = entry.listDisplayName || getBoardListDisplayName(entry.listName); + listGroup.appendChild(option); + } + select.appendChild(listGroup); + + select.value = getBoardTableListFilter(entries); + select.addEventListener('change', async () => { + const nextListFilter = String(select.value || BOARD_TABLE_LIST_FILTER_ALL); + + if (typeof waitForNativeMenuTrackingToSettle === 'function') { + await waitForNativeMenuTrackingToSettle(); + } + + if (!select.isConnected || select.value !== nextListFilter) { + return; + } + + setBoardTableListFilter(nextListFilter); + await renderBoard(); + }); + + control.appendChild(select); + return control; +} + +function createBoardTableBulkButton(label, options = {}) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'board-table-bulk-button'; + button.textContent = label; + + if (options.destructive) { + button.classList.add('board-table-bulk-button-danger'); + } + + if (options.active) { + button.classList.add('is-active'); + } + + if (options.title) { + button.title = options.title; + } + + if (options.ariaLabel) { + button.setAttribute('aria-label', options.ariaLabel); + } + + if (options.menuId) { + button.setAttribute('aria-haspopup', 'true'); + button.setAttribute('aria-expanded', options.active ? 'true' : 'false'); + } + + if (typeof options.onClick === 'function') { + button.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await options.onClick(button); + }); + } + + return button; +} + +function toggleBoardTableBulkMenu(menuId) { + const state = getBoardTableState(); + state.activeBulkMenu = state.activeBulkMenu === menuId ? '' : menuId; +} + +function normalizeBoardTableDateInput(dateValue) { + const normalized = String(dateValue || '').trim(); + if (!normalized) { + return ''; + } + + if (typeof normalizeTaskDateValue === 'function') { + return normalizeTaskDateValue(normalized); + } + + return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : ''; +} + +function getBoardTableBulkOperationMessage(successCount, failureCount, verb) { + const success = Number(successCount) || 0; + const failure = Number(failureCount) || 0; + const cardLabel = success === 1 ? 'card' : 'cards'; + if (failure > 0 && success > 0) { + return `${verb} ${success} ${cardLabel}; ${failure} failed.`; + } + if (failure > 0) { + return `Could not update ${failure} ${failure === 1 ? 'card' : 'cards'}.`; + } + return `${verb} ${success} ${cardLabel}.`; +} + +function announceBoardTableBulkStatus(message) { + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus(message); + } +} + +async function archiveBoardTableSelectedCards(selectedEntries) { + const entries = Array.isArray(selectedEntries) ? selectedEntries : []; + if (entries.length === 0) { + return; + } + + const warningMessage = `Archive ${entries.length} selected card${entries.length === 1 ? '' : 's'}?\n\nThis will move ${entries.length === 1 ? 'it' : 'them'} into XXX-Archive.`; + if (!window.confirm(warningMessage)) { + return; + } + + let archivedCount = 0; + let failureCount = 0; + for (const entry of entries) { + try { + await window.board.archiveCard(entry.cardPath); + archivedCount += 1; + } catch (error) { + failureCount += 1; + console.error('Failed to archive selected table card.', error); + } + } + + clearBoardTableSelection(); + await renderBoard(); + announceBoardTableBulkStatus(getBoardTableBulkOperationMessage(archivedCount, failureCount, 'Archived')); +} + +async function moveBoardTableSelectedCards(selectedEntries, targetListPath) { + const normalizedTargetListPath = String(targetListPath || '').trim(); + const entries = (Array.isArray(selectedEntries) ? selectedEntries : []) + .filter((entry) => entry && entry.cardPath && entry.listPath !== normalizedTargetListPath); + if (!normalizedTargetListPath || entries.length === 0) { + announceBoardTableBulkStatus('No selected cards needed to move.'); + return; + } + + let movedCount = 0; + let failureCount = 0; + for (const entry of entries.slice().reverse()) { + try { + const newCardPath = await moveBoardTableCardToList(entry, normalizedTargetListPath); + if (newCardPath) { + movedCount += 1; + } + } catch (error) { + failureCount += 1; + console.error('Failed to move selected table card.', error); + } + } + + clearBoardTableSelection(); + await renderBoard(); + announceBoardTableBulkStatus(getBoardTableBulkOperationMessage(movedCount, failureCount, 'Moved')); +} + +function getBoardTableLabelIdsFromMenu(menu) { + if (!menu || typeof menu.querySelectorAll !== 'function') { + return []; + } + + return Array.from(menu.querySelectorAll('input[data-label-id]:checked')) + .map((input) => String(input.dataset.labelId || '').trim()) + .filter(Boolean); +} + +function getBoardTableLabelsWithAddedIds(currentLabelIds, addedLabelIds) { + const current = Array.isArray(currentLabelIds) ? currentLabelIds.map((labelId) => String(labelId)) : []; + const existing = new Set(current); + const next = current.slice(); + const selected = new Set((Array.isArray(addedLabelIds) ? addedLabelIds : []).map((labelId) => String(labelId))); + + for (const label of getBoardLabels()) { + if (selected.has(label.id) && !existing.has(label.id)) { + existing.add(label.id); + next.push(label.id); + } + } + + return next; +} + +function getBoardTableLabelsWithoutIds(currentLabelIds, removedLabelIds) { + const removed = new Set((Array.isArray(removedLabelIds) ? removedLabelIds : []).map((labelId) => String(labelId))); + return (Array.isArray(currentLabelIds) ? currentLabelIds : []) + .map((labelId) => String(labelId)) + .filter((labelId) => !removed.has(labelId)); +} + +async function updateBoardTableSelectedLabels(selectedEntries, labelIds, mode) { + const entries = Array.isArray(selectedEntries) ? selectedEntries : []; + const selectedLabelIds = Array.isArray(labelIds) ? labelIds.filter(Boolean) : []; + const normalizedMode = mode === 'remove' ? 'remove' : 'add'; + if (entries.length === 0 || selectedLabelIds.length === 0) { + announceBoardTableBulkStatus('Choose at least one label.'); + return; + } + + let updatedCount = 0; + let failureCount = 0; + for (const entry of entries) { + const nextLabels = normalizedMode === 'remove' + ? getBoardTableLabelsWithoutIds(entry.labels, selectedLabelIds) + : getBoardTableLabelsWithAddedIds(entry.labels, selectedLabelIds); + const currentLabels = Array.isArray(entry.labels) ? entry.labels.map((labelId) => String(labelId)) : []; + if (nextLabels.join('\u0000') === currentLabels.join('\u0000')) { + continue; + } + + try { + await window.board.updateFrontmatter(entry.cardPath, { labels: nextLabels }); + updatedCount += 1; + } catch (error) { + failureCount += 1; + console.error('Failed to update labels for selected table card.', error); + } + } + + clearBoardTableSelection(); + await renderBoard(); + announceBoardTableBulkStatus(getBoardTableBulkOperationMessage( + updatedCount, + failureCount, + normalizedMode === 'remove' ? 'Updated labels on' : 'Updated labels on', + )); +} + +async function updateBoardTableSelectedDate(selectedEntries, fieldName, dateValue) { + const entries = Array.isArray(selectedEntries) ? selectedEntries : []; + const normalizedFieldName = fieldName === 'start' ? 'start' : 'due'; + const normalizedDate = normalizeBoardTableDateInput(dateValue); + const isClearing = !String(dateValue || '').trim(); + + if (entries.length === 0) { + return; + } + + if (!isClearing && !normalizedDate) { + announceBoardTableBulkStatus('Use a valid YYYY-MM-DD date.'); + return; + } + + if (isClearing) { + const label = normalizedFieldName === 'start' ? 'start date' : 'due date'; + const warningMessage = `Clear the ${label} from ${entries.length} selected card${entries.length === 1 ? '' : 's'}?`; + if (!window.confirm(warningMessage)) { + return; + } + } + + let updatedCount = 0; + let failureCount = 0; + for (const entry of entries) { + const currentDate = String(entry[normalizedFieldName] || '').trim(); + if (currentDate === normalizedDate) { + continue; + } + + try { + await window.board.updateFrontmatter(entry.cardPath, { + [normalizedFieldName]: normalizedDate, + }); + updatedCount += 1; + } catch (error) { + failureCount += 1; + console.error(`Failed to update ${normalizedFieldName} date for selected table card.`, error); + } + } + + clearBoardTableSelection(); + await renderBoard(); + const verb = isClearing + ? `Cleared ${normalizedFieldName} date on` + : `Set ${normalizedFieldName} date on`; + announceBoardTableBulkStatus(getBoardTableBulkOperationMessage(updatedCount, failureCount, verb)); +} + +function createBoardTableBulkMoveMenu(selectedEntries, listOptions) { + const menu = document.createElement('div'); + menu.className = 'board-table-bulk-menu board-table-bulk-move-menu'; + menu.setAttribute('role', 'group'); + menu.setAttribute('aria-label', 'Move selected cards'); + + for (const optionEntry of listOptions) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'board-table-bulk-menu-option'; + button.textContent = optionEntry.listDisplayName; + button.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await moveBoardTableSelectedCards(selectedEntries, optionEntry.listPath); + }); + menu.appendChild(button); + } + + return menu; +} + +function createBoardTableBulkLabelsMenu(selectedEntries) { + const menu = document.createElement('div'); + menu.className = 'board-table-bulk-menu board-table-bulk-labels-menu'; + menu.setAttribute('role', 'group'); + menu.setAttribute('aria-label', 'Update selected card labels'); + + const labels = getBoardLabels(); + if (labels.length === 0) { + const empty = document.createElement('p'); + empty.className = 'board-table-bulk-menu-empty'; + empty.textContent = 'No labels yet.'; + menu.appendChild(empty); + return menu; + } + + const labelList = document.createElement('div'); + labelList.className = 'board-table-bulk-label-list'; + for (const label of labels) { + const row = document.createElement('label'); + row.className = 'board-table-bulk-label-row'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.labelId = label.id; + + const swatch = document.createElement('span'); + swatch.className = 'label-color-swatch'; + swatch.style.backgroundColor = getBoardLabelColor(label); + + const text = document.createElement('span'); + text.textContent = label.name; + + row.appendChild(checkbox); + row.appendChild(swatch); + row.appendChild(text); + labelList.appendChild(row); + } + menu.appendChild(labelList); + + const actions = document.createElement('div'); + actions.className = 'board-table-bulk-menu-actions'; + + const addButton = createBoardTableBulkButton('Add labels', { + onClick: async () => { + await updateBoardTableSelectedLabels(selectedEntries, getBoardTableLabelIdsFromMenu(menu), 'add'); + }, + }); + const removeButton = createBoardTableBulkButton('Remove labels', { + onClick: async () => { + await updateBoardTableSelectedLabels(selectedEntries, getBoardTableLabelIdsFromMenu(menu), 'remove'); + }, + }); + + actions.appendChild(addButton); + actions.appendChild(removeButton); + menu.appendChild(actions); + + return menu; +} + +function createBoardTableBulkDateRow(labelText, fieldName, selectedEntries) { + const row = document.createElement('div'); + row.className = 'board-table-bulk-date-row'; + + const label = document.createElement('label'); + label.className = 'board-table-bulk-date-label'; + label.textContent = labelText; + + const input = document.createElement('input'); + input.type = 'date'; + input.className = 'board-table-bulk-date-input'; + input.setAttribute('aria-label', `${labelText} date`); + label.appendChild(input); + + const setButton = createBoardTableBulkButton('Set', { + onClick: async () => { + await updateBoardTableSelectedDate(selectedEntries, fieldName, input.value); + }, + }); + const clearButton = createBoardTableBulkButton('Clear', { + onClick: async () => { + await updateBoardTableSelectedDate(selectedEntries, fieldName, ''); + }, + }); + + row.appendChild(label); + row.appendChild(setButton); + row.appendChild(clearButton); + return row; +} + +function createBoardTableBulkDatesMenu(selectedEntries) { + const menu = document.createElement('div'); + menu.className = 'board-table-bulk-menu board-table-bulk-dates-menu'; + menu.setAttribute('role', 'group'); + menu.setAttribute('aria-label', 'Update selected card dates'); + menu.appendChild(createBoardTableBulkDateRow('Start', 'start', selectedEntries)); + menu.appendChild(createBoardTableBulkDateRow('Due', 'due', selectedEntries)); + return menu; +} + +function createBoardTableBulkMenuWrap(label, menuId, menuFactory, options = {}) { + const state = getBoardTableState(); + const isActive = state.activeBulkMenu === menuId; + const wrap = document.createElement('div'); + wrap.className = 'board-table-bulk-menu-wrap'; + + const button = createBoardTableBulkButton(label, { + ...options, + menuId, + active: isActive, + onClick: async () => { + toggleBoardTableBulkMenu(menuId); + await renderBoard(); + }, + }); + wrap.appendChild(button); + + if (isActive) { + wrap.appendChild(menuFactory()); + } + + return wrap; +} + +function createBoardTableBulkToolbar(selectedEntries, visibleEntries, listOptions) { + const toolbar = document.createElement('div'); + toolbar.className = 'board-table-bulk-toolbar'; + toolbar.setAttribute('aria-label', 'Bulk card actions'); + + const count = selectedEntries.length; + const countText = document.createElement('span'); + countText.className = 'board-table-bulk-count'; + countText.textContent = `${count} selected`; + toolbar.appendChild(countText); + + toolbar.appendChild(createBoardTableBulkButton('Archive', { + destructive: true, + onClick: async () => { + await archiveBoardTableSelectedCards(selectedEntries); + }, + })); + + toolbar.appendChild(createBoardTableBulkMenuWrap( + 'Move', + 'move', + () => createBoardTableBulkMoveMenu(selectedEntries, listOptions), + )); + + toolbar.appendChild(createBoardTableBulkMenuWrap( + 'Labels', + 'labels', + () => createBoardTableBulkLabelsMenu(selectedEntries), + )); + + toolbar.appendChild(createBoardTableBulkMenuWrap( + 'Dates', + 'dates', + () => createBoardTableBulkDatesMenu(selectedEntries), + )); + + toolbar.appendChild(createBoardTableBulkButton('Clear', { + onClick: async () => { + clearBoardTableSelection(); + await renderBoard(); + }, + })); + + if (visibleEntries.length === 0) { + toolbar.hidden = true; + } + + return toolbar; +} + +function createBoardTableHeader(visibleEntries) { const thead = document.createElement('thead'); const row = document.createElement('tr'); @@ -307,7 +993,29 @@ function createBoardTableHeader() { const header = document.createElement('th'); header.scope = 'col'; header.className = `board-table-heading board-table-heading-${column.id}`; - header.textContent = column.label; + + if (column.id === 'select') { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'board-table-select-checkbox board-table-select-all-checkbox'; + checkbox.title = 'Select visible cards'; + checkbox.setAttribute('aria-label', 'Select visible cards'); + + const entries = Array.isArray(visibleEntries) ? visibleEntries : []; + const selectedEntries = getBoardTableSelectedEntries(entries); + checkbox.checked = entries.length > 0 && selectedEntries.length === entries.length; + checkbox.indeterminate = selectedEntries.length > 0 && selectedEntries.length < entries.length; + checkbox.disabled = entries.length === 0; + checkbox.addEventListener('click', async (event) => { + event.stopPropagation(); + selectBoardTableVisibleEntries(entries, checkbox.checked); + await renderBoard(); + }); + + header.appendChild(checkbox); + } else { + header.textContent = column.label; + } row.appendChild(header); } @@ -315,6 +1023,29 @@ function createBoardTableHeader() { return thead; } +function createBoardTableSelectionCell(entry, visibleEntries) { + const cell = document.createElement('td'); + cell.className = 'board-table-cell board-table-cell-select'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'board-table-select-checkbox'; + checkbox.checked = isBoardTableEntrySelected(entry); + checkbox.title = `Select ${entry.title}`; + checkbox.setAttribute('aria-label', `Select ${entry.title}`); + checkbox.addEventListener('click', async (event) => { + event.stopPropagation(); + selectBoardTableEntryRange(entry, visibleEntries, checkbox.checked, Boolean(event.shiftKey)); + await renderBoard(); + }); + + cell.addEventListener('click', (event) => { + event.stopPropagation(); + }); + cell.appendChild(checkbox); + return cell; +} + function createBoardTableTimestampCell(entry, timestampKey) { const cell = document.createElement('td'); cell.className = `board-table-cell board-table-cell-${timestampKey === 'createdAt' ? 'created' : 'updated'}`; @@ -596,11 +1327,13 @@ function createBoardTableLabelsCell(entry) { return cell; } -async function createBoardTableRow(entry, listOptions) { +async function createBoardTableRow(entry, listOptions, visibleEntries) { const row = document.createElement('tr'); row.className = 'board-table-row'; + row.classList.toggle('is-selected', isBoardTableEntrySelected(entry)); row.dataset.path = entry.cardPath; row.dataset.listPath = entry.listPath; + row.setAttribute('aria-selected', isBoardTableEntrySelected(entry) ? 'true' : 'false'); row.addEventListener('click', (event) => { const target = event.target instanceof Element ? event.target : null; if (target && target.closest && target.closest('button, select, input, textarea, a')) { @@ -610,6 +1343,7 @@ async function createBoardTableRow(entry, listOptions) { toggleEditCardModal(entry.cardPath); }); + row.appendChild(createBoardTableSelectionCell(entry, visibleEntries)); row.appendChild(await createBoardTableStartCell(entry)); row.appendChild(await createBoardTableDueCell(entry)); row.appendChild(createBoardTableTimestampCell(entry, 'updatedAt')); @@ -647,17 +1381,30 @@ function createBoardTableEmptyState(totalCount) { async function renderTableBoard(boardRoot, listsWithCards) { const tableState = await collectBoardTableCards(boardRoot, listsWithCards); const listOptions = getBoardTableListOptions(tableState.listEntries); + const visibleCards = sortBoardTableCards(tableState.visibleCards); + pruneBoardTableSelection(visibleCards); + const selectedEntries = getBoardTableSelectedEntries(visibleCards); const tableView = document.createElement('section'); tableView.className = 'board-table-view'; const tableHeader = document.createElement('div'); tableHeader.className = 'board-table-header'; - tableHeader.appendChild(createBoardTableSortControl()); - tableHeader.appendChild(createBoardTableSummary( + const tableHeaderLeft = document.createElement('div'); + tableHeaderLeft.className = 'board-table-header-left'; + if (selectedEntries.length > 0) { + tableHeaderLeft.appendChild(createBoardTableBulkToolbar(selectedEntries, visibleCards, listOptions)); + } + const tableHeaderRight = document.createElement('div'); + tableHeaderRight.className = 'board-table-header-right'; + tableHeaderRight.appendChild(createBoardTableListFilterControl(tableState.listEntries)); + tableHeaderRight.appendChild(createBoardTableSortControl()); + tableHeaderRight.appendChild(createBoardTableSummary( tableState.visibleCards.length, tableState.allCards.length, )); + tableHeader.appendChild(tableHeaderLeft); + tableHeader.appendChild(tableHeaderRight); tableView.appendChild(tableHeader); if (tableState.visibleCards.length === 0) { @@ -675,12 +1422,11 @@ async function renderTableBoard(boardRoot, listsWithCards) { const table = document.createElement('table'); table.className = 'board-table'; - table.appendChild(createBoardTableHeader()); + table.appendChild(createBoardTableHeader(visibleCards)); const tbody = document.createElement('tbody'); - const visibleCards = sortBoardTableCards(tableState.visibleCards); const rows = await Promise.all( - visibleCards.map((entry) => createBoardTableRow(entry, listOptions)), + visibleCards.map((entry) => createBoardTableRow(entry, listOptions, visibleCards)), ); for (const row of rows) { diff --git a/docs/README.md b/docs/README.md index a477130..e40c7b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ If you want structured tool access from an agent, see [MCP Server](../MCP_README - Creating and organizing boards, lists, and cards - Drag-and-drop movement, including the empty insertion slot shown while dragging cards -- Start/due date ranges on Kanban cards, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting in Table, Planner date views, and External Published Calendar +- Start/due date ranges on Kanban cards, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting and bulk actions in Table, Planner date views, and External Published Calendar - Archiving and restoring cards and lists - Settings, including app-wide Settings/Notifications/Smart Actions panels and board-specific General, Labels, Appearance, Workflow, Obsidian, and Import panels - Obsidian integration, including boards stored inside vaults, Open With actions, generated Bases files, linked notes, missing-note handling, linked objects, dropped local-file linking, URL favicons, `signboard://` card/board links, and the optional Obsidian companion plugin diff --git a/docs/codex/FILE_STRUCTURE.md b/docs/codex/FILE_STRUCTURE.md index 7db4795..c6dd8a1 100644 --- a/docs/codex/FILE_STRUCTURE.md +++ b/docs/codex/FILE_STRUCTURE.md @@ -37,7 +37,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal app/current-board panel nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. - `app/board/boardSearch.js` - Board search state, input handling for title/body filtering, and keyboard navigation from the search field through visible card results. - `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card start/due and open task start/due markers, and source-list/source-board pills on temporal cards. -- `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table sort controls, and list-column card moves through the top-of-list move IPC path. +- `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table list filter and sort controls, checkbox/shift-range bulk selection, bulk archive/move/label/date actions, and list-column card moves through the top-of-list move IPC path. - `app/board/plannerView.js` - Workspace-level Planner overlay with Calendar, This Week, Day, and Agenda views across currently open boards, all/current/custom board scope controls, Planner-local search/date/completed-card/board/active-board-label filters, keyboard navigation for Planner search/filter controls, left-rail open/close behavior, and Planner card opening that switches the active board when needed. - `app/board/archiveBrowser.js` - Dedicated Archive modal UI, search-first archived card/list browsing with keyboard result navigation, detail-pane rendering, incremental result loading, and restore flows. - `app/board/boardTabs.js` - Open-board tab session state (restore/add/close/reorder), last-known open/active board snapshot sync to main process, keyboard navigation/close behavior for visible tabs, responsive `N more` overflow for unbounded open boards, plus the shared safe board-switch helper used by tab clicks and the switcher. diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index db157c8..0624e09 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -159,13 +159,14 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - `app/board/renderBoard.js`: - Reads list metadata and renders the active board view. - Renders Kanban columns by default. - - Renders the board-scoped Table view for dense card scanning. + - Renders the board-scoped Table view for dense card scanning and bulk management. - Enables list drag-and-drop reorder in Kanban. - Fetches each list's card names concurrently for faster initial render. - Loads board label definitions and temporary filter state before rendering cards. - `app/board/tableView.js`: - Renders active-board cards in board/list order as a dense table. - - Shows `Start`, `Due`, `Updated`, `Created`, and linked-object count columns plus a compact sort control for board order, oldest/newest updated, oldest/newest created, due date, and title. + - Shows `Start`, `Due`, `Updated`, `Created`, and linked-object count columns plus compact list-filter and sort controls. + - Supports visible-row checkbox selection, shift-range selection after a row checkbox is selected, and bulk actions for archive, move-to-list, add/remove labels, set/clear start dates, and set/clear due dates. - Reuses board search, label filters, Today/Overdue/next-range date filters, task progress badges, linked-object counts, and completed-list workflow handling. - Moves a card to another list through the row list dropdown by calling the same top-of-list move IPC path as the card editor. - Defers row list dropdown DOM updates until macOS native menu tracking has settled. diff --git a/docs/using-signboard.md b/docs/using-signboard.md index 1874641..95ce065 100644 --- a/docs/using-signboard.md +++ b/docs/using-signboard.md @@ -232,11 +232,13 @@ Kanban is the board view. Use it for day-to-day drag-and-drop organization. Card ### Table -Table is an active-board view for scanning cards in board/list order. It uses the same board search, label filters, date filters, task progress badges, linked-object counts, and completed-list workflow rules as Kanban. +Table is an active-board view for scanning and bulk-managing cards in board/list order. It uses the same board search, label filters, date filters, task progress badges, linked-object counts, and completed-list workflow rules as Kanban. Open `Board menu > View > Table`. Click a card title or row to open the normal card editor. Use the row's list dropdown to move a card to another list; moved cards land at the top of the destination list. -Table includes `Start`, `Due`, `Updated`, and `Created` columns plus a sort control. Sort by `Updated, oldest first` to find cards you have not touched in a while, or by `Created, oldest first` to find your oldest cards. Search and filters apply first, then the Table sort orders the visible cards. +Table includes `Start`, `Due`, `Updated`, and `Created` columns plus list filtering and sorting. Filter to one list, all completed lists, or all lists. Sort by `Updated, oldest first` to find cards you have not touched in a while, or by `Created, oldest first` to find your oldest cards. Search and filters apply first, then the Table sort orders the visible cards. + +Use the row checkboxes to select visible cards for bulk actions. After selecting one card, hold `Shift` while checking another row to select the range between them. The header checkbox selects the currently visible rows only. Bulk actions can archive selected cards, move them to another list, add or remove labels, set or clear start dates, and set or clear due dates. ### Dated Views @@ -314,6 +316,8 @@ Open the card editor and choose the archive action. Open the list actions menu and choose `Archive cards in this list`. +For selective cleanup, switch to Table, filter to `Completed lists` or a specific list, select the cards you want, and choose `Archive` from the bulk toolbar. + ### Archive a list Open the list actions menu and choose `Archive this list`. diff --git a/readme.md b/readme.md index 78f65e9..5c49df9 100644 --- a/readme.md +++ b/readme.md @@ -18,7 +18,7 @@ Signboard is free for personal use. If you are using Signboard for your work it - ๐ŸŒ™ Light and dark mode variants for all color schemes - ๐Ÿท Custom labels per board - ๐Ÿ—“ Compact card start/due date ranges and task list item start/due dates -- ๐Ÿ“‹ Kanban and Table board views, including card age columns and sorting in Table +- ๐Ÿ“‹ Kanban and Table board views, including card age columns, sorting, list filtering, and bulk actions in Table - ๐Ÿ—‚ Planner overlay for actionable dated work across open boards - ๐Ÿ“† Optional local External Published Calendar feed for calendar app subscriptions - ๐Ÿ”ฎ Obsidian-friendly properties, Bases generation, linked objects, linked-object counts, and `signboard://` card links diff --git a/scripts/test-board-views.js b/scripts/test-board-views.js index 88cd823..428204c 100644 --- a/scripts/test-board-views.js +++ b/scripts/test-board-views.js @@ -529,7 +529,7 @@ async function run() { const tableHeader = context.createBoardTableHeader(); assert.deepStrictEqual( toPlain(tableHeader.children[0].children.map((headerCell) => headerCell.textContent)), - ['Start', 'Due', 'Updated', 'Created', 'Tasks', 'Links', 'Card', 'List', 'Labels'], + ['', 'Start', 'Due', 'Updated', 'Created', 'Tasks', 'Links', 'Card', 'List', 'Labels'], 'expected table columns to keep the configured order', ); @@ -785,6 +785,55 @@ async function run() { assert.strictEqual(unfilteredTableState.visibleCards.length, 3, 'expected table view to include every unfiltered card'); assert.strictEqual(unfilteredTableState.allCards[0].linkedObjectCount, 2, 'expected table collection to count linked objects'); + context.getBoardTableState().listFilter = 'completed'; + const completedListTableState = await context.collectBoardTableCards('/tmp/board/', tableLists); + assert.deepStrictEqual( + toPlain(completedListTableState.visibleCards.map((card) => card.title)), + ['Finished overdue'], + 'expected table list filter to show completed-list cards', + ); + context.getBoardTableState().listFilter = 'list:/tmp/board/000-To-do-stock'; + const singleListTableState = await context.collectBoardTableCards('/tmp/board/', tableLists); + assert.deepStrictEqual( + toPlain(singleListTableState.visibleCards.map((card) => card.title)), + ['Alpha task', 'Beta overdue'], + 'expected table list filter to show cards from one list', + ); + context.getBoardTableState().listFilter = 'all'; + + context.clearBoardTableSelection(); + const tableSelectionEntries = context.sortBoardTableCards(unfilteredTableState.visibleCards); + context.selectBoardTableEntryRange(tableSelectionEntries[0], tableSelectionEntries, true, false); + assert.deepStrictEqual( + toPlain(context.getBoardTableSelectedEntries(tableSelectionEntries).map((card) => card.title)), + ['Alpha task'], + 'expected first selected table row to seed selection', + ); + context.selectBoardTableEntryRange(tableSelectionEntries[2], tableSelectionEntries, true, true); + assert.deepStrictEqual( + toPlain(context.getBoardTableSelectedEntries(tableSelectionEntries).map((card) => card.title)), + ['Alpha task', 'Beta overdue', 'Finished overdue'], + 'expected shift-select to select the row range', + ); + context.selectBoardTableEntryRange(tableSelectionEntries[1], tableSelectionEntries, false, true); + assert.deepStrictEqual( + toPlain(context.getBoardTableSelectedEntries(tableSelectionEntries).map((card) => card.title)), + ['Alpha task'], + 'expected shift-unselect to clear the row range', + ); + context.clearBoardTableSelection(); + + assert.deepStrictEqual( + toPlain(context.getBoardTableLabelsWithAddedIds(['label-2'], ['label-1', 'label-2'])), + ['label-2', 'label-1'], + 'expected bulk add labels to preserve existing labels and append new labels', + ); + assert.deepStrictEqual( + toPlain(context.getBoardTableLabelsWithoutIds(['label-1', 'label-2'], ['label-1'])), + ['label-2'], + 'expected bulk remove labels to remove only selected labels', + ); + filterState.filterIds = ['label-1']; const labelFilteredTableState = await context.collectBoardTableCards('/tmp/board/', tableLists); assert.deepStrictEqual( diff --git a/static/styles.css b/static/styles.css index 156ab8d..f8ce1f1 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1241,19 +1241,38 @@ body:not(.board-empty) main#board.board-view-week { .board-table-view { width: 100%; - min-width: 1210px; + min-width: 1280px; } .board-table-header { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 12px; margin-bottom: 8px; min-height: 24px; } -.board-table-sort-control { +.board-table-header-left, +.board-table-header-right { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.board-table-header-left { + flex: 1 1 auto; + justify-content: flex-start; +} + +.board-table-header-right { + flex: 0 0 auto; + justify-content: flex-end; +} + +.board-table-sort-control, +.board-table-filter-control { display: inline-flex; align-items: center; gap: 6px; @@ -1261,12 +1280,14 @@ body:not(.board-empty) main#board.board-view-week { font-size: var(--font-xs); } -.board-table-sort-label { +.board-table-sort-label, +.board-table-filter-label { font-weight: 700; line-height: 1; } -.board-table-sort-select { +.board-table-sort-select, +.board-table-filter-select { min-height: 30px; border: 1px solid var(--border); border-radius: var(--radius); @@ -1276,7 +1297,169 @@ body:not(.board-empty) main#board.board-view-week { padding: 4px 28px 4px 8px; } -.board-table-sort-select:focus-visible { +.board-table-sort-select:focus-visible, +.board-table-filter-select:focus-visible { + outline: 3px solid color-mix(in oklab, var(--accent) 45%, var(--bg-card)); + outline-offset: 1px; +} + +.board-table-bulk-toolbar { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 4px; + border: 1px solid color-mix(in oklab, var(--accent) 28%, var(--border)); + border-radius: var(--radius); + background: color-mix(in oklab, var(--accent) 8%, var(--bg-card)); +} + +.board-table-bulk-count { + padding: 0 6px; + color: var(--muted); + font-family: "JetBrains Mono Local", "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 11px; + font-weight: 600; + letter-spacing: .01em; + white-space: nowrap; +} + +.board-table-bulk-button { + min-height: 28px; + border: 1px solid transparent; + border-radius: var(--radius-xs); + background: transparent; + color: var(--text); + font-size: var(--font-xs); + font-weight: 700; + line-height: 1.2; + padding: 5px 8px; +} + +.board-table-bulk-button:hover, +.board-table-bulk-button.is-active { + border-color: var(--border); + background: var(--bg-card); +} + +.board-table-bulk-button-danger { + color: var(--danger); +} + +.board-table-bulk-button:focus-visible { + outline: 3px solid color-mix(in oklab, var(--accent) 45%, var(--bg-card)); + outline-offset: 1px; +} + +.board-table-bulk-menu-wrap { + position: relative; + display: inline-flex; +} + +.board-table-bulk-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 5; + min-width: 230px; + max-width: min(320px, calc(100vw - 32px)); + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + box-shadow: 0 14px 36px var(--shadow); +} + +.board-table-bulk-menu-option { + display: block; + width: 100%; + min-height: 32px; + border: 0; + border-radius: var(--radius-xs); + background: transparent; + color: var(--text); + font-size: var(--font-sm); + line-height: 1.25; + padding: 7px 8px; + text-align: left; +} + +.board-table-bulk-menu-option:hover, +.board-table-bulk-menu-option:focus-visible { + background: color-mix(in oklab, var(--accent) 10%, var(--bg-card)); +} + +.board-table-bulk-label-list { + display: grid; + gap: 2px; + max-height: 220px; + overflow: auto; +} + +.board-table-bulk-label-row { + display: grid; + grid-template-columns: 18px 14px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 32px; + border-radius: var(--radius-xs); + color: var(--text); + font-size: var(--font-sm); + padding: 6px 8px; +} + +.board-table-bulk-label-row:hover { + background: color-mix(in oklab, var(--accent) 8%, var(--bg-card)); +} + +.board-table-bulk-menu-empty { + margin: 0; + color: var(--muted); + font-size: var(--font-sm); +} + +.board-table-bulk-menu-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in oklab, var(--border) 78%, var(--bg-card)); +} + +.board-table-bulk-date-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: end; + gap: 6px; + padding: 6px 0; +} + +.board-table-bulk-date-row + .board-table-bulk-date-row { + border-top: 1px solid color-mix(in oklab, var(--border) 78%, var(--bg-card)); +} + +.board-table-bulk-date-label { + display: grid; + gap: 4px; + color: var(--muted); + font-size: var(--font-xs); + font-weight: 700; +} + +.board-table-bulk-date-input { + min-height: 30px; + min-width: 142px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--bg-card); + color: var(--text); + font-size: var(--font-sm); + padding: 4px 7px; +} + +.board-table-bulk-date-input:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, var(--bg-card)); outline-offset: 1px; } @@ -1328,6 +1511,10 @@ body:not(.board-empty) main#board.board-view-week { cursor: pointer; } +.board-table-row.is-selected .board-table-cell { + background: color-mix(in oklab, var(--accent) 9%, var(--bg-card)); +} + .board-table-cell { padding: 10px 12px; border-bottom: 1px solid color-mix(in oklab, var(--border) 78%, var(--bg-card)); @@ -1343,6 +1530,34 @@ body:not(.board-empty) main#board.board-view-week { background: color-mix(in oklab, var(--bg-card) 88%, var(--bg)); } +.board-table-row.is-selected:hover .board-table-cell { + background: color-mix(in oklab, var(--accent) 13%, var(--bg-card)); +} + +.board-table-heading-select, +.board-table-cell-select { + width: 44px; + min-width: 44px; + text-align: center; +} + +.board-table-cell-select { + cursor: default; +} + +.board-table-select-checkbox { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); + vertical-align: middle; +} + +.board-table-select-checkbox:focus-visible { + outline: 3px solid color-mix(in oklab, var(--accent) 45%, var(--bg-card)); + outline-offset: 2px; +} + .board-table-cell-title { width: 34%; min-width: 260px; @@ -2429,11 +2644,18 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, .list, .modal, .card-date-popover, - .label-popover { + .label-popover, + .board-table-bulk-toolbar, + .board-table-bulk-menu { border: 1px solid CanvasText; box-shadow: none; } + .board-table-row.is-selected .board-table-cell { + background: Highlight; + color: HighlightText; + } + #modalEditCard .cardEditorTimestampMetadata { color: CanvasText; } diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index 2337b73..5b6aa84 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -199,6 +199,17 @@ async function pathExists(targetPath) { } } +async function findFixtureCardPath(boardRoot, listName, fileNamePart) { + const listPath = path.join(boardRoot, listName); + const entries = await fs.readdir(listPath); + const match = entries.find((entry) => entry.includes(fileNamePart)); + if (!match) { + throw new Error(`Unable to find fixture card containing "${fileNamePart}" in ${listName}.`); + } + + return path.join(listPath, match); +} + async function seedBoardState(page, boardRoot) { await seedOpenBoardState(page, [boardRoot], boardRoot); } @@ -1784,6 +1795,103 @@ test('switches to table view and moves a card through the list column', async ({ await expect(page.locator('#modalEditCard')).toBeHidden(); }); +test('bulk manages selected table cards', async ({ page, boardRoot }) => { + await page.locator('#boardSearchInput').focus(); + await page.keyboard.press(getCurrentBoardPlannerShortcut('1')); + await expect(page.locator('main#board')).toHaveClass(/board-view-table/); + await expect(page.locator('.board-table-row')).toHaveCount(3); + + await page.locator('.board-table-filter-select').selectOption({ label: 'Completed lists' }); + await expect(page.locator('.board-table-row')).toHaveCount(1); + await expect(page.locator('.board-table-row')).toContainText('Ship beta'); + await page.locator('.board-table-filter-select').selectOption({ label: 'All lists' }); + await expect(page.locator('.board-table-row')).toHaveCount(3); + + const rows = page.locator('.board-table-row'); + await rows.nth(0).locator('.board-table-select-checkbox').click(); + await rows.nth(2).locator('.board-table-select-checkbox').click({ modifiers: ['Shift'] }); + await expect(page.locator('.board-table-bulk-count')).toHaveText('3 selected'); + await expect(rows.nth(1)).toHaveAttribute('aria-selected', 'true'); + + await page.getByRole('button', { name: 'Labels' }).click(); + const labelsMenu = page.locator('.board-table-bulk-labels-menu'); + await expect(labelsMenu).toBeVisible(); + await labelsMenu.locator('.board-table-bulk-label-row').filter({ hasText: 'Content' }).locator('input').check(); + await labelsMenu.getByRole('button', { name: 'Add labels' }).click(); + await expect(page.locator('.board-table-bulk-toolbar')).toHaveCount(0); + + const planPath = await findFixtureCardPath(boardRoot, '000-To-do-stock', 'plan-release'); + const shipPath = await findFixtureCardPath(boardRoot, '002-Done-stock', 'ship-beta'); + await expect.poll(async () => { + const planCard = await cardFrontmatter.readCard(planPath); + const shipCard = await cardFrontmatter.readCard(shipPath); + return { + planLabels: planCard.frontmatter.labels || [], + shipLabels: shipCard.frontmatter.labels || [], + }; + }).toEqual({ + planLabels: ['launch', 'content'], + shipLabels: ['content'], + }); + + await rows.nth(0).locator('.board-table-select-checkbox').click(); + await rows.nth(1).locator('.board-table-select-checkbox').click({ modifiers: ['Shift'] }); + await expect(page.locator('.board-table-bulk-count')).toHaveText('2 selected'); + await page.getByRole('button', { name: 'Dates' }).click(); + const datesMenu = page.locator('.board-table-bulk-dates-menu'); + const dueRow = datesMenu.locator('.board-table-bulk-date-row').filter({ hasText: 'Due' }); + await dueRow.locator('input').fill('2026-04-20'); + await dueRow.getByRole('button', { name: 'Set' }).click(); + await expect.poll(async () => { + const planCard = await cardFrontmatter.readCard(planPath); + const polishPath = await findFixtureCardPath(boardRoot, '001-Doing-stock', 'polish-copy'); + const polishCard = await cardFrontmatter.readCard(polishPath); + return [planCard.frontmatter.due || '', polishCard.frontmatter.due || '']; + }).toEqual(['2026-04-20', '2026-04-20']); + + await rows.nth(0).locator('.board-table-select-checkbox').click(); + await rows.nth(1).locator('.board-table-select-checkbox').click({ modifiers: ['Shift'] }); + await page.getByRole('button', { name: 'Dates' }).click(); + const clearDueRow = page.locator('.board-table-bulk-dates-menu .board-table-bulk-date-row').filter({ hasText: 'Due' }); + page.once('dialog', (dialog) => dialog.accept()); + await clearDueRow.getByRole('button', { name: 'Clear' }).click(); + await expect.poll(async () => { + const planCard = await cardFrontmatter.readCard(planPath); + const polishPath = await findFixtureCardPath(boardRoot, '001-Doing-stock', 'polish-copy'); + const polishCard = await cardFrontmatter.readCard(polishPath); + return [planCard.frontmatter.due || '', polishCard.frontmatter.due || '']; + }).toEqual(['', '']); + + await rows.nth(0).locator('.board-table-select-checkbox').click(); + await rows.nth(2).locator('.board-table-select-checkbox').click({ modifiers: ['Shift'] }); + await page.getByRole('button', { name: 'Move' }).click(); + await page.locator('.board-table-bulk-move-menu').getByRole('button', { name: 'Doing' }).click(); + await expect.poll(async () => { + const toDoEntries = await fs.readdir(path.join(boardRoot, '000-To-do-stock')); + const doingEntries = await fs.readdir(path.join(boardRoot, '001-Doing-stock')); + const doneEntries = await fs.readdir(path.join(boardRoot, '002-Done-stock')); + return { + toDo: toDoEntries.filter((entry) => entry.endsWith('.md')).length, + doing: doingEntries.filter((entry) => entry.endsWith('.md')).length, + done: doneEntries.filter((entry) => entry.endsWith('.md')).length, + }; + }).toEqual({ + toDo: 0, + doing: 3, + done: 0, + }); + + await page.locator('.board-table-select-all-checkbox').click(); + await expect(page.locator('.board-table-bulk-count')).toHaveText('3 selected'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Archive' }).click(); + await expect(page.locator('.board-table-row')).toHaveCount(0); + await expect.poll(async () => { + const archiveEntries = await fs.readdir(path.join(boardRoot, 'XXX-Archive')); + return archiveEntries.filter((entry) => entry.endsWith('.md')).length; + }).toBe(3); +}); + test('updates task item due dates from Planner calendar drops', async ({ page, boardRoot }) => { const cardPath = path.join(boardRoot, '000-To-do-stock', '000-plan-release-stock.md'); const sourceIso = formatLocalIsoDate(getCurrentMonthDate(10)); From cca7c4641cb8def6fe8aae839f168d964aedbf45 Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Mon, 22 Jun 2026 21:32:13 -0400 Subject: [PATCH 05/10] Fixed bulk due date layout --- static/styles.css | 12 ++++++++++-- tests/playwright/signboard-smoke.spec.js | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/static/styles.css b/static/styles.css index f8ce1f1..0f8657c 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1430,7 +1430,7 @@ body:not(.board-empty) main#board.board-view-week { .board-table-bulk-date-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(150px, 1fr) auto auto; align-items: end; gap: 6px; padding: 6px 0; @@ -1443,14 +1443,17 @@ body:not(.board-empty) main#board.board-view-week { .board-table-bulk-date-label { display: grid; gap: 4px; + min-width: 0; color: var(--muted); font-size: var(--font-xs); font-weight: 700; } .board-table-bulk-date-input { + box-sizing: border-box; + width: 100%; min-height: 30px; - min-width: 142px; + min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--bg-card); @@ -1464,6 +1467,11 @@ body:not(.board-empty) main#board.board-view-week { outline-offset: 1px; } +.board-table-bulk-date-row .board-table-bulk-button { + min-width: 54px; + text-align: center; +} + .board-table-summary { margin: 0; color: var(--muted); diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index 5b6aa84..37eccca 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -210,6 +210,18 @@ async function findFixtureCardPath(boardRoot, listName, fileNamePart) { return path.join(listPath, match); } +async function expectBulkDateRowControlsDoNotOverlap(rowLocator) { + const inputBox = await rowLocator.locator('input').boundingBox(); + const setBox = await rowLocator.getByRole('button', { name: 'Set' }).boundingBox(); + const clearBox = await rowLocator.getByRole('button', { name: 'Clear' }).boundingBox(); + + expect(inputBox).toBeTruthy(); + expect(setBox).toBeTruthy(); + expect(clearBox).toBeTruthy(); + expect(inputBox.x + inputBox.width).toBeLessThanOrEqual(setBox.x - 1); + expect(setBox.x + setBox.width).toBeLessThanOrEqual(clearBox.x - 1); +} + async function seedBoardState(page, boardRoot) { await seedOpenBoardState(page, [boardRoot], boardRoot); } @@ -1839,6 +1851,8 @@ test('bulk manages selected table cards', async ({ page, boardRoot }) => { await expect(page.locator('.board-table-bulk-count')).toHaveText('2 selected'); await page.getByRole('button', { name: 'Dates' }).click(); const datesMenu = page.locator('.board-table-bulk-dates-menu'); + await expectBulkDateRowControlsDoNotOverlap(datesMenu.locator('.board-table-bulk-date-row').nth(0)); + await expectBulkDateRowControlsDoNotOverlap(datesMenu.locator('.board-table-bulk-date-row').nth(1)); const dueRow = datesMenu.locator('.board-table-bulk-date-row').filter({ hasText: 'Due' }); await dueRow.locator('input').fill('2026-04-20'); await dueRow.getByRole('button', { name: 'Set' }).click(); From 0647911c585e3ddb65093bba3913771857ddbdf2 Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Tue, 23 Jun 2026 09:12:33 -0400 Subject: [PATCH 06/10] Adjusted Smart Card actions. --- AGENTS.md | 8 +- CODEX.md | 11 +- app/appSettings.js | 350 ++------------ app/board/boardLabels.js | 164 +++++-- app/cards/createCardElement.js | 42 +- app/modals/toggleEditCardModal.js | 566 +++++++++++++++++------ buildjs.sh | 1 + docs/README.md | 4 +- docs/codex/FILE_STRUCTURE.md | 21 +- docs/codex/PROJECT_CONTEXT.md | 28 +- docs/using-signboard.md | 7 +- index.html | 14 +- lib/aiTaskSuggestions.js | 168 ++++++- lib/appSettings.js | 363 +-------------- lib/boardDuplication.js | 300 ++++++++++++ main.js | 19 + package.json | 1 + preload.js | 1 + readme.md | 5 +- scripts/test-ai-task-suggestions.js | 84 +++- scripts/test-app-settings.js | 16 +- scripts/test-board-duplication.js | 133 ++++++ shared/appSettingsSchema.js | 403 ++++++++++++++++ static/styles.css | 88 ++-- tests/playwright/signboard-smoke.spec.js | 129 +++++- 25 files changed, 1981 insertions(+), 945 deletions(-) create mode 100644 lib/boardDuplication.js create mode 100644 scripts/test-board-duplication.js create mode 100644 shared/appSettingsSchema.js diff --git a/AGENTS.md b/AGENTS.md index eea9b11..8259efa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Read [docs/codex/PROJECT_CONTEXT.md](./docs/codex/PROJECT_CONTEXT.md) for architecture and behavior. - Read [docs/codex/FILE_STRUCTURE.md](./docs/codex/FILE_STRUCTURE.md) for the repository map. - Read [DESIGN.md](./DESIGN.md) before changing the default theme, visual system, or UI component styling. -- Treat `app/signboard.js` as generated output; edit source modules in `app/**` and then run `./buildjs.sh`. +- Treat `app/signboard.js` as generated output; edit source modules in `app/**` or shared renderer modules such as `shared/appSettingsSchema.js`, then run `./buildjs.sh`. - Keep keyboard shortcut behavior, `index.html` shortcut help, and user-facing shortcut docs in sync. - Keep modal focus handling, live status announcements, reduced-motion behavior, forced-colors behavior, and keyboard-only focus styling in sync with `app/utilities/accessibility.js` and `static/styles.css`. - Mark body-level popovers that must remain interactive while a modal is active with `data-sb-modal-layer`, so the modal background inert handler does not disable them. @@ -28,10 +28,10 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Obsidian integration writes flat Obsidian-friendly card properties, detects containing vaults by walking upward for `.obsidian`, auto-creates/updates managed `Signboard Board.base` files without overwriting user-customized Bases, supports metadata-only linked Obsidian notes named `Linked Signboard Note.md` from the card editor only for cards inside detected vaults, shows the Obsidian-vault-required info modal for linked-note/Base actions outside a vault, and resolves `signboard://open-card` links only through trusted board roots. `signboard://open-board?path=...` opens validated vault-contained board folders after confirmation. The optional Obsidian companion plugin lives in `obsidian-plugin/` and can create/open Signboard boards from Obsidian. Linked objects use structured `linked_objects` frontmatter for Obsidian notes, local files/folders, web URLs, app deep links, and Signboard links; local files can be linked by picker or by dragging files onto the card editor, and Kanban/Table linked-object counts use `app/utilities/linkedObjects.js`. Raw web URLs in the card body stay as Markdown text but are visually marked in the OverType preview and opened through `window.electronAPI.openExternal` from the inline open button or Cmd/Ctrl-click. Keep `lib/obsidianIntegration.js`, `main.js`, preload, renderer menus/views, plugin helpers, and docs aligned. - The sponsorship modal opens from the Board menu "Sponsor" action, About modal action, and fixed bottom-right "Sponsor" pill; the pill hides on compact windows to avoid covering board lists. - Quick Add card creation supports board/list selection across open boards, an optional app-level global shortcut while Signboard is running, and `Shift + Enter` to create, immediately open, and focus the notes field on the new card. -- Settings groups app-wide panels as Settings, Notifications, and Smart Actions, followed by current-board panels ordered General, Labels, Appearance, Workflow, Obsidian, and Import. -- AI assistance is opt-in in App Settings. Ollama settings and Smart Card Action prompts live in app settings, main-process AI requests run through `lib/aiTaskSuggestions.js`, settings verify the configured Ollama URL through `/api/tags` and load installed models into a refreshable dropdown, and the Card Editor floating Smart Card Actions button must preview generated titles, task lists, smart paste Markdown, and custom-action output before changing a card. +- Settings groups app-wide panels as Settings, Notifications, and Smart Actions, followed by current-board panels ordered General, Labels, Appearance, Workflow, Obsidian, and Import; General includes board rename, move, and duplicate actions. +- AI assistance is opt-in in App Settings. Ollama settings and Smart Card Action prompts live in app settings, default Smart Card Action prompts live in `shared/appSettingsSchema.js`, main-process AI requests run through `lib/aiTaskSuggestions.js`, settings verify the configured Ollama URL through `/api/tags` and load installed models into a refreshable dropdown, and the Card Editor floating Smart Card Actions button must preview generated titles, summaries, task lists, smart paste Markdown, auto-label suggestions, and custom-action output before changing a card. Its menu gear opens the Smart Actions settings panel directly. Generated task-list quantity lives in the prompt text, not a separate setting. Auto-label suggestions must resolve to existing current-board labels and merge only new label IDs into the card. - Card `start` frontmatter and task `(start: YYYY-MM-DD)` / `(scheduled: YYYY-MM-DD)` markers feed Kanban, Table, Planner, CLI, MCP, and importers alongside due dates; completed checklist date markers and completed-list cards stay non-actionable by default. -- Kanban cards show card-level `start` and `due` through one compact Dates control and body-level popover; the card editor keeps separate start/due controls. +- Kanban cards and the card editor show card-level `start` and `due` through one compact calendar-based Dates control and shared popover; task lines use one calendar control per task for start/due editing. - Card label popovers can create board labels inline, open the board's Labels settings from a gear shortcut, and Add Card/Quick Add can preselect active-board labels before creating a card. - External Published Calendar is opt-in in App Settings, served only on `127.0.0.1`, and board inclusion is controlled in each board's Workflow settings. - Completed-list workflow settings live in board settings; Planner, board date filters, and due notifications treat completed-list cards as non-actionable by default while preserving due dates. diff --git a/CODEX.md b/CODEX.md index e84a06f..0b04f1a 100644 --- a/CODEX.md +++ b/CODEX.md @@ -5,7 +5,7 @@ Start here before opening source files. - Read `docs/codex/PROJECT_CONTEXT.md` for architecture, data model, and behavior. - Read `docs/codex/FILE_STRUCTURE.md` for an annotated map of the repository. - Read `DESIGN.md` before changing the default theme, visual system, or UI component styling. -- Treat `app/signboard.js` as generated output; edit the source modules in `app/**` and then run `./buildjs.sh`. +- Treat `app/signboard.js` as generated output; edit the source modules in `app/**` or shared renderer modules such as `shared/appSettingsSchema.js`, then run `./buildjs.sh`. - Tooltip UI is implemented in `app/ui/tooltips.js` and reads existing control labels (`title` / `aria-label` / `alt`) to keep tooltip copy centralized in markup. - Accessibility helpers live in `app/utilities/accessibility.js`; keep modal focus restoration/trapping, modal-layer popovers marked with `data-sb-modal-layer`, live status announcements, keyboard-only `:focus-visible` styling, reduced-motion behavior, and forced-colors support aligned with UI changes. - App updates are handled in `main.js` via `electron-updater` (GitHub releases), with menu-triggered/manual checks and remind-later state in `update-preferences.json` under Electron `userData`. The native app menu is also validated on focus and rebuilt if required Signboard actions are missing. @@ -28,8 +28,8 @@ Start here before opening source files. - Board rendering supports Kanban by default plus a board-scoped Table view for scanning and bulk-managing cards; Calendar, This Week, Day, and Agenda dated workflows live in Planner. - Planner is managed in `app/board/plannerView.js`; it appears as a left-edge overlay only when at least one board tab is open, defaults to all open boards, has a quick current-board scope toggle, and owns Calendar, This Week, Day, and Agenda views. - Planner search/filtering is separate from board search: it searches card title/body plus board/list source text, filters by `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days`, completed-card visibility, and selected open boards, and exposes label filtering only when scoped to the active board. -- App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut, AI assistance/Ollama settings, and External Published Calendar settings are stored only in app settings. -- AI assistance is opt-in in App Settings. The first provider is Ollama, called from the main process through `lib/aiTaskSuggestions.js`; settings verify the configured Ollama URL through `/api/tags`, populate a local-model dropdown with refresh support, store customizable Smart Card Action prompts, and the Card Editor floating Smart Card Actions button previews generated titles, generated task lists, smart paste Markdown, and custom-action output before changing a card. Card context is sent only when an action is used. +- App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; shared defaults/normalizers, including built-in Smart Card Action prompts, live in `shared/appSettingsSchema.js`. Tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut, AI assistance/Ollama settings, and External Published Calendar settings are stored only in app settings. +- AI assistance is opt-in in App Settings. The first provider is Ollama, called from the main process through `lib/aiTaskSuggestions.js`; settings verify the configured Ollama URL through `/api/tags`, populate a local-model dropdown with refresh support, store customizable Smart Card Action prompts, and the Card Editor floating Smart Card Actions button previews generated titles, summaries, task lists, smart paste Markdown, auto-label suggestions, and custom-action output before changing a card. Its menu gear opens the Smart Actions settings panel directly. Generated task-list quantity lives in the prompt text, not a separate setting. Auto-label suggestions must resolve to existing current-board labels and merge only new label IDs into the card. Card context is sent only when an action is used. - Board-level workflow settings in `board-settings.md` auto-detect completed lists by name (`Done`, `Completed`, `Complete`, `Closed`, `Finished`, `Resolved`, `Shipped`) with manual overrides; Planner, board date filters, and due notifications hide completed-list cards by default while preserving due dates. - Planner uses the default Signboard palette for the active light/dark mode instead of inheriting the active board color scheme; Planner cards show `Board ยท List` source context with source pills tinted from each card's board color scheme. - Opening a Planner card switches the active board behind the overlay when needed before opening the normal editor so label and list controls remain board-correct. @@ -39,7 +39,7 @@ Start here before opening source files. - Board, Planner, and Archive search support keyboard result navigation: `Enter` / arrows from the search field move into visible results, arrows move between results, `Esc` returns to search, and `Esc` from search clears the query where applicable. Board tabs, list actions, label/filter popovers, and Settings sections also support arrow-key movement plus `Home` / `End`; popovers restore focus to their opener on `Esc`, and focused board tabs close with `Delete` / `Backspace`. - The header filter popover is owned by `app/board/boardLabels.js`; it supports temporary `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days` date filters plus multi-select label filters for Kanban and Table. Card-label popovers can create board labels inline, open the board's Labels settings from a gear shortcut, and Add Card/Quick Add can assign existing or newly created active-board labels before the card file is written. - Date filters and Planner date views intentionally ignore completed task-level date markers and completed-list cards by default; card-level `start`/`due` dates still count for actionable lists, and incomplete task `start`/`due` markers still drive matches in Kanban, Table, and Planner. -- Kanban cards expose card-level `start` and `due` through one compact Dates metadata control and body-level popover; the card editor keeps separate start/due controls. +- Kanban cards and the card editor expose card-level `start` and `due` through one compact calendar-based Dates control and shared two-field popover; task-line date editing in the editor uses one calendar control per task line for start/due. - The filter toolbar button is icon-only; when filters are active it gets an accent-tinted active state and exposes the active summary through tooltip/ARIA text rather than visible label text. - Keyboard shortcut handling is centralized in `app/listeners/window.js`; the helper modal is rendered in `index.html` as `#modalKeyboardShortcuts`, renderer shortcuts cover Quick Add card creation (`Cmd/Ctrl + N`, with board/list selection across open boards), quick board switching (`Cmd/Ctrl + K`), board view switching (`Cmd/Ctrl + 1` closes Planner back to Kanban, `Cmd/Ctrl + Option/Alt + 1` closes Planner back to Table), Planner toggle/view switching (`Cmd/Ctrl + Shift + P`, `Cmd/Ctrl + 2/3/4/5` opens the matching Planner date view for all open boards, `Cmd/Ctrl + Option/Alt + 2/3/4/5` opens the matching Planner date view scoped to the current board), Settings (`Cmd/Ctrl + ,`), color-scheme cycling, archive browsing, and active-card move/archive actions. Workspace-level shortcuts close the active card editor before changing context, while editor-scoped move/archive shortcuts keep acting on the open card. Native menu accelerators cover board switching, board view switching, Settings, and theme toggling. The optional app-level Quick Add global shortcut is registered in `main.js` with Electron `globalShortcut` and sends the same renderer Quick Add command while Signboard is running. - Shortcut label formatting is shared from `app/board/boardLabels.js`, so the keyboard helper modal, board/view menus, and list-action popovers all stay OS-aware and in sync. @@ -60,6 +60,7 @@ Start here before opening source files. - AI task suggestion helper coverage is in `scripts/test-ai-task-suggestions.js` (`npm run test:ai-task-suggestions`). - External Published Calendar coverage is in `scripts/test-external-published-calendar.js` (`npm run test:external-calendar`). - Due notification coverage tests are in `scripts/test-due-notifications.js` (`npm run test:due-notifications`). +- Board duplication coverage is in `scripts/test-board-duplication.js` (`npm run test:board-duplication`). - Dedicated user-facing MCP setup docs are in `MCP_README.md`. - Release-facing user and agent docs live in `docs/README.md`, `docs/using-signboard.md`, and `docs/signboard-cli.md`. - Reusable agent skill for MCP usage lives at `skills/signboard-mcp/SKILL.md`. @@ -67,7 +68,7 @@ Start here before opening source files. - Board tabs/session state live in renderer localStorage: `openBoardPaths` (open tab order), `activeBoardPath` (active board root), and legacy `boardPath` fallback. The renderer also syncs open/active board state into Electron `userData/open-boards.json` so MCP and CLI discovery can report last-known desktop-open boards. - Open board count is unbounded in session state; the tab strip keeps visible tabs compact, collapses overflow behind an `N more` tab that opens the board switcher, and the switcher can close open boards. - Board label definitions are managed in `board-settings.md` files inside each board folder (runtime data, not repo source). -- Settings groups app-wide panels as Settings, Notifications, and Smart Actions, followed by current-board panels ordered General, Labels, Appearance, Workflow, Obsidian, and Import; the renderer wiring lives in `app/board/boardLabels.js`, while the actual import filesystem work lives in `lib/importers/*` through `main.js` IPC. +- Settings groups app-wide panels as Settings, Notifications, and Smart Actions, followed by current-board panels ordered General, Labels, Appearance, Workflow, Obsidian, and Import; the renderer wiring lives in `app/board/boardLabels.js`, while import filesystem work lives in `lib/importers/*` and board duplication lives in `lib/boardDuplication.js` through `main.js` IPC. - The sponsorship modal is opened from the Board menu "Sponsor" action, About modal action, and fixed bottom-right "Sponsor" pill; the pill hides on compact windows to avoid covering board lists. - External import pickers are tokenized in `main.js` and surfaced through `window.chooser.pickImportSources(...)`; renderer code never reads arbitrary external files directly. - Trello, Obsidian, and Tasks.md importer coverage lives in `scripts/test-import-trello.js`, `scripts/test-import-obsidian.js`, and `scripts/test-import-tasksmd.js`. diff --git a/app/appSettings.js b/app/appSettings.js index db11f11..821c345 100644 --- a/app/appSettings.js +++ b/app/appSettings.js @@ -1,66 +1,32 @@ -const DEFAULT_APP_NOTIFICATION_SETTINGS = Object.freeze({ - enabled: false, - time: '09:00', -}); -const DEFAULT_APP_TOOLTIPS_ENABLED = true; -const DEFAULT_APP_QUICK_ADD_SETTINGS = Object.freeze({ - globalShortcut: '', -}); -const DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS = Object.freeze({ - enabled: false, - port: 48273, - token: '', -}); -const APP_SMART_CARD_ACTION_LABEL_MAX_LENGTH = 80; -const APP_SMART_CARD_ACTION_PROMPT_MAX_LENGTH = 6000; -const APP_CUSTOM_SMART_CARD_ACTION_LIMIT = 12; -const DEFAULT_APP_SMART_CARD_ACTIONS = Object.freeze([ - Object.freeze({ - id: 'generate-title', - type: 'title', - label: 'Generate new title', - prompt: [ - 'Improve the card title using the current title and card body.', - 'Keep it concise, specific, and action-oriented.', - 'Preserve the original intent and do not add facts that are not supported by the card.', - ].join('\n'), - builtIn: true, - }), - Object.freeze({ - id: 'generate-task-list', - type: 'tasks', - label: 'Generate task list', - prompt: [ - 'Generate practical checklist items for this card.', - 'Infer common next actions from the title, body, board, list, and labels.', - 'Do not duplicate existing checklist items.', - 'Use short imperative task text.', - ].join('\n'), - builtIn: true, - }), - Object.freeze({ - id: 'smart-paste', - type: 'paste', - label: 'Smart paste', - prompt: [ - 'Format the pasted information for this Signboard Markdown card.', - 'Preserve the complete useful information from the pasted text, including names, dates, decisions, links, and requirements.', - 'Use clear Markdown sections, a concise summary when helpful, task list items for follow-up work, and reference URLs when present.', - 'Do not invent facts.', - ].join('\n'), - builtIn: true, - }), -]); -const DEFAULT_APP_AI_SETTINGS = Object.freeze({ - enabled: false, - provider: 'ollama', - ollama: Object.freeze({ - url: 'http://127.0.0.1:11434', - model: 'llama3.2', - taskCount: 6, - }), - smartCardActions: DEFAULT_APP_SMART_CARD_ACTIONS, -}); +const APP_SETTINGS_SCHEMA = typeof SignboardAppSettingsSchema !== 'undefined' + ? SignboardAppSettingsSchema + : null; + +if (!APP_SETTINGS_SCHEMA) { + throw new Error('SignboardAppSettingsSchema must be loaded before app/appSettings.js.'); +} + +const DEFAULT_APP_NOTIFICATION_SETTINGS = APP_SETTINGS_SCHEMA.DEFAULT_NOTIFICATION_SETTINGS; +const DEFAULT_APP_TOOLTIPS_ENABLED = APP_SETTINGS_SCHEMA.DEFAULT_TOOLTIPS_ENABLED; +const DEFAULT_APP_QUICK_ADD_SETTINGS = APP_SETTINGS_SCHEMA.DEFAULT_QUICK_ADD_SETTINGS; +const DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS = APP_SETTINGS_SCHEMA.DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS; +const APP_SMART_CARD_ACTION_LABEL_MAX_LENGTH = APP_SETTINGS_SCHEMA.SMART_CARD_ACTION_LABEL_MAX_LENGTH; +const APP_SMART_CARD_ACTION_PROMPT_MAX_LENGTH = APP_SETTINGS_SCHEMA.SMART_CARD_ACTION_PROMPT_MAX_LENGTH; +const APP_CUSTOM_SMART_CARD_ACTION_LIMIT = APP_SETTINGS_SCHEMA.CUSTOM_SMART_CARD_ACTION_LIMIT; +const DEFAULT_APP_SMART_CARD_ACTIONS = APP_SETTINGS_SCHEMA.DEFAULT_SMART_CARD_ACTIONS; +const DEFAULT_APP_AI_SETTINGS = APP_SETTINGS_SCHEMA.cloneDefaultAiSettings(); +const cloneDefaultAppSmartCardActions = APP_SETTINGS_SCHEMA.cloneDefaultSmartCardActions; +const normalizeAppNotificationSettings = APP_SETTINGS_SCHEMA.normalizeNotificationSettings; +const normalizeAppTooltipsEnabled = APP_SETTINGS_SCHEMA.normalizeTooltipsEnabled; +const normalizeAppGlobalShortcutAccelerator = APP_SETTINGS_SCHEMA.normalizeGlobalShortcutAccelerator; +const normalizeAppQuickAddSettings = APP_SETTINGS_SCHEMA.normalizeQuickAddSettings; +const normalizeAppExternalPublishedCalendarPort = APP_SETTINGS_SCHEMA.normalizeExternalPublishedCalendarPort; +const normalizeAppExternalPublishedCalendarSettings = APP_SETTINGS_SCHEMA.normalizeExternalPublishedCalendarSettings; +const normalizeAppSmartCardActionLabel = APP_SETTINGS_SCHEMA.normalizeSmartCardActionLabel; +const normalizeAppSmartCardActionPrompt = APP_SETTINGS_SCHEMA.normalizeSmartCardActionPrompt; +const normalizeAppSmartCardActionId = APP_SETTINGS_SCHEMA.normalizeSmartCardActionId; +const normalizeAppSmartCardActions = APP_SETTINGS_SCHEMA.normalizeSmartCardActions; +const normalizeAppAiSettings = APP_SETTINGS_SCHEMA.normalizeAiSettings; const DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_STATUS = Object.freeze({ enabled: false, running: false, @@ -106,259 +72,6 @@ function getAppSettingsState() { return window.__signboardAppSettingsState; } -function normalizeAppNotificationTime(value) { - const candidate = String(value || '').trim(); - if (/^(?:0[1-9]|1\d|2[0-4]):[0-5]\d$/.test(candidate)) { - return candidate; - } - - return DEFAULT_APP_NOTIFICATION_SETTINGS.time; -} - -function normalizeAppNotificationSettings(notificationSettings) { - const source = notificationSettings && typeof notificationSettings === 'object' && !Array.isArray(notificationSettings) - ? notificationSettings - : {}; - return { - enabled: source.enabled === true, - time: normalizeAppNotificationTime(source.time), - }; -} - -function normalizeAppTooltipsEnabled(value) { - return value === false ? false : DEFAULT_APP_TOOLTIPS_ENABLED; -} - -function normalizeAppGlobalShortcutAccelerator(value) { - const candidate = String(value || '') - .trim() - .replace(/\s*\+\s*/g, '+') - .replace(/\s+/g, ''); - - if (!candidate || candidate.length > 80) { - return ''; - } - - return candidate; -} - -function normalizeAppQuickAddSettings(quickAddSettings) { - const source = quickAddSettings && typeof quickAddSettings === 'object' && !Array.isArray(quickAddSettings) - ? quickAddSettings - : {}; - - return { - globalShortcut: normalizeAppGlobalShortcutAccelerator(source.globalShortcut), - }; -} - -function normalizeAppExternalPublishedCalendarPort(value) { - const parsedPort = Number.parseInt(String(value || ''), 10); - if (Number.isInteger(parsedPort) && parsedPort >= 1024 && parsedPort <= 65535) { - return parsedPort; - } - - return DEFAULT_APP_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS.port; -} - -function normalizeAppExternalPublishedCalendarToken(value) { - const candidate = String(value || '').trim(); - if (!candidate || candidate.length > 160) { - return ''; - } - - return /^[A-Za-z0-9._~-]+$/.test(candidate) ? candidate : ''; -} - -function normalizeAppExternalPublishedCalendarSettings(calendarSettings) { - const source = calendarSettings && typeof calendarSettings === 'object' && !Array.isArray(calendarSettings) - ? calendarSettings - : {}; - - return { - enabled: source.enabled === true, - port: normalizeAppExternalPublishedCalendarPort(source.port), - token: normalizeAppExternalPublishedCalendarToken(source.token), - }; -} - -function normalizeAppAiProvider(value) { - return value === 'ollama' ? 'ollama' : DEFAULT_APP_AI_SETTINGS.provider; -} - -function normalizeAppOllamaUrl(value) { - let candidate = String(value || '').trim(); - if (!candidate) { - candidate = DEFAULT_APP_AI_SETTINGS.ollama.url; - } - - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { - candidate = `http://${candidate}`; - } - - try { - const parsed = new URL(candidate); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return DEFAULT_APP_AI_SETTINGS.ollama.url; - } - - parsed.username = ''; - parsed.password = ''; - parsed.search = ''; - parsed.hash = ''; - - const basePath = parsed.pathname && parsed.pathname !== '/' - ? parsed.pathname.replace(/\/+$/, '') - : ''; - return `${parsed.origin}${basePath}`; - } catch { - return DEFAULT_APP_AI_SETTINGS.ollama.url; - } -} - -function normalizeAppOllamaModel(value) { - const candidate = String(value || '').trim(); - if (!candidate || candidate.length > 120 || /[\s\x00-\x1F]/.test(candidate)) { - return DEFAULT_APP_AI_SETTINGS.ollama.model; - } - - return candidate; -} - -function normalizeAppAiTaskCount(value) { - const parsedCount = Number.parseInt(String(value || ''), 10); - if (Number.isInteger(parsedCount) && parsedCount >= 3 && parsedCount <= 12) { - return parsedCount; - } - - return DEFAULT_APP_AI_SETTINGS.ollama.taskCount; -} - -function cloneDefaultAppSmartCardActions() { - return DEFAULT_APP_SMART_CARD_ACTIONS.map((action) => ({ ...action })); -} - -function normalizeAppSmartCardActionLabel(value, fallback = '') { - const candidate = String(value || '') - .replace(/\s+/g, ' ') - .trim(); - const normalizedFallback = String(fallback || '').trim(); - if (!candidate) { - return normalizedFallback; - } - - return candidate.slice(0, APP_SMART_CARD_ACTION_LABEL_MAX_LENGTH).trim() || normalizedFallback; -} - -function normalizeAppSmartCardActionPrompt(value, fallback = '') { - const candidate = String(value || '') - .replace(/\r\n?/g, '\n') - .trim(); - const normalizedFallback = String(fallback || '').trim(); - if (!candidate) { - return normalizedFallback; - } - - return candidate.slice(0, APP_SMART_CARD_ACTION_PROMPT_MAX_LENGTH).trim() || normalizedFallback; -} - -function normalizeAppSmartCardActionId(value, fallback = '') { - const candidate = String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80); - const normalizedFallback = String(fallback || '').trim(); - return candidate || normalizedFallback; -} - -function normalizeAppSmartCardActions(actions) { - const sourceActions = Array.isArray(actions) ? actions : []; - const normalizedActions = []; - const seenIds = new Set(); - - for (const defaultAction of DEFAULT_APP_SMART_CARD_ACTIONS) { - const sourceAction = sourceActions.find((action) => ( - action && typeof action === 'object' && !Array.isArray(action) && String(action.id || '') === defaultAction.id - )); - normalizedActions.push({ - ...defaultAction, - label: defaultAction.label, - prompt: normalizeAppSmartCardActionPrompt( - sourceAction && Object.prototype.hasOwnProperty.call(sourceAction, 'prompt') - ? sourceAction.prompt - : defaultAction.prompt, - defaultAction.prompt, - ), - builtIn: true, - }); - seenIds.add(defaultAction.id); - } - - let customCount = 0; - for (const action of sourceActions) { - if (!action || typeof action !== 'object' || Array.isArray(action) || action.builtIn === true) { - continue; - } - - const label = normalizeAppSmartCardActionLabel(action.label); - const prompt = normalizeAppSmartCardActionPrompt(action.prompt); - if (!label || !prompt) { - continue; - } - - const fallbackId = `custom-${customCount + 1}`; - let id = normalizeAppSmartCardActionId(action.id, fallbackId); - if (DEFAULT_APP_SMART_CARD_ACTIONS.some((defaultAction) => defaultAction.id === id)) { - id = fallbackId; - } - while (seenIds.has(id)) { - id = `custom-${customCount + 1}-${seenIds.size + 1}`; - } - - normalizedActions.push({ - id, - type: 'custom', - label, - prompt, - builtIn: false, - }); - seenIds.add(id); - customCount += 1; - if (customCount >= APP_CUSTOM_SMART_CARD_ACTION_LIMIT) { - break; - } - } - - return normalizedActions; -} - -function normalizeAppOllamaSettings(ollamaSettings) { - const source = ollamaSettings && typeof ollamaSettings === 'object' && !Array.isArray(ollamaSettings) - ? ollamaSettings - : {}; - - return { - url: normalizeAppOllamaUrl(source.url), - model: normalizeAppOllamaModel(source.model), - taskCount: normalizeAppAiTaskCount(source.taskCount), - }; -} - -function normalizeAppAiSettings(aiSettings) { - const source = aiSettings && typeof aiSettings === 'object' && !Array.isArray(aiSettings) - ? aiSettings - : {}; - - return { - enabled: source.enabled === true, - provider: normalizeAppAiProvider(source.provider), - ollama: normalizeAppOllamaSettings(source.ollama), - smartCardActions: normalizeAppSmartCardActions(source.smartCardActions || source.cardActions), - }; -} - function normalizeAppGlobalShortcutStatus(status) { const source = status && typeof status === 'object' && !Array.isArray(status) ? status @@ -622,7 +335,6 @@ function renderAppSettingsControls() { const aiOllamaModelSelect = document.getElementById('boardSettingsAiOllamaModel'); const aiOllamaRefreshButton = document.getElementById('btnRefreshAiOllamaModels'); const aiOllamaStatus = document.getElementById('boardSettingsAiOllamaStatus'); - const aiTaskCountInput = document.getElementById('boardSettingsAiTaskCount'); const aiActionsList = document.getElementById('boardSettingsAiActionsList'); const aiAddActionButton = document.getElementById('btnAddAiSmartCardAction'); const notifications = getAppNotificationSettings(); @@ -735,10 +447,6 @@ function renderAppSettingsControls() { } } - if (aiTaskCountInput) { - aiTaskCountInput.value = String(aiSettings.ollama.taskCount); - } - if (aiActionsList) { renderAppSmartCardActionSettings(aiActionsList, aiSettings.smartCardActions); } diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index 239f9e3..ad4c6b9 100644 --- a/app/board/boardLabels.js +++ b/app/board/boardLabels.js @@ -2759,6 +2759,8 @@ function getBoardSettingsNavButtonId(panelId) { function renderBoardGeneralSettingsControls() { const boardNameInput = document.getElementById('boardSettingsBoardNameInput'); const boardPathInput = document.getElementById('boardSettingsBoardPathInput'); + const duplicateNameInput = document.getElementById('boardSettingsDuplicateNameInput'); + const duplicateStatus = document.getElementById('boardSettingsDuplicateStatus'); const tooltipsToggle = document.getElementById('boardSettingsTooltipsToggle'); const boardInfo = getBoardRootInfo(); @@ -2770,6 +2772,22 @@ function renderBoardGeneralSettingsControls() { boardPathInput.value = boardInfo ? boardInfo.normalizedRoot.replace(/\/+$/, '') : ''; } + if (duplicateNameInput) { + const currentSourceRoot = duplicateNameInput.dataset.sourceBoardRoot || ''; + if (!boardInfo) { + duplicateNameInput.value = ''; + duplicateNameInput.dataset.sourceBoardRoot = ''; + } else if (currentSourceRoot !== boardInfo.normalizedRoot || !duplicateNameInput.value.trim()) { + duplicateNameInput.value = `${boardInfo.boardName} Copy`; + duplicateNameInput.dataset.sourceBoardRoot = boardInfo.normalizedRoot; + } + } + + if (duplicateStatus) { + duplicateStatus.textContent = ''; + duplicateStatus.classList.remove('is-success', 'is-warning'); + } + if (tooltipsToggle) { tooltipsToggle.checked = getBoardTooltipsEnabled(); } @@ -3242,6 +3260,43 @@ async function moveCurrentBoardDirectory(nextParentDirectory) { return moveBoardDirectory(nextBoardRoot); } +async function duplicateCurrentBoardDirectory(boardNameRaw, destinationParentSelection) { + const boardInfo = getBoardRootInfo(); + if (!boardInfo || !window.board || typeof window.board.duplicateBoard !== 'function') { + return false; + } + + const nextBoardName = sanitizeBoardDirectoryName(boardNameRaw); + if (!nextBoardName) { + return false; + } + + const destinationParentDirectory = getDirectorySelectionPath(destinationParentSelection); + if (!destinationParentDirectory || !destinationParentSelection || !destinationParentSelection.token) { + return false; + } + + await flushBoardSettingsSave(); + const result = await window.board.duplicateBoard(boardInfo.normalizedRoot, { + boardName: nextBoardName, + destinationParentToken: destinationParentSelection.token, + }); + const duplicatedBoardRoot = normalizeBoardPath(result && result.boardRoot); + if (!duplicatedBoardRoot) { + return false; + } + + if (typeof ensureBoardInTabs === 'function') { + ensureBoardInTabs(duplicatedBoardRoot); + } + + await openBoard(duplicatedBoardRoot); + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus(`Duplicated board: ${nextBoardName}.`); + } + return true; +} + function openBoardSettingsModal(options = {}) { const modal = document.getElementById('modalBoardSettings'); if (!modal) { @@ -3389,6 +3444,9 @@ function initializeBoardLabelControls() { const renameBoardInput = document.getElementById('boardSettingsBoardNameInput'); const renameBoardButton = document.getElementById('btnRenameBoard'); const moveBoardButton = document.getElementById('btnMoveBoard'); + const duplicateBoardInput = document.getElementById('boardSettingsDuplicateNameInput'); + const duplicateBoardButton = document.getElementById('btnDuplicateBoard'); + const duplicateBoardStatus = document.getElementById('boardSettingsDuplicateStatus'); const colorSchemeSelect = document.getElementById('boardColorSchemeSelect'); const applyThemeToOpenBoardsButton = document.getElementById('btnApplyThemeColorsToOpenBoards'); const notificationsToggle = document.getElementById('boardSettingsNotificationsToggle'); @@ -3399,7 +3457,6 @@ function initializeBoardLabelControls() { const aiOllamaUrlInput = document.getElementById('boardSettingsAiOllamaUrl'); const aiOllamaModelSelect = document.getElementById('boardSettingsAiOllamaModel'); const aiOllamaRefreshButton = document.getElementById('btnRefreshAiOllamaModels'); - const aiTaskCountInput = document.getElementById('boardSettingsAiTaskCount'); const aiActionsList = document.getElementById('boardSettingsAiActionsList'); const aiAddActionButton = document.getElementById('btnAddAiSmartCardAction'); const externalCalendarToggle = document.getElementById('boardSettingsExternalCalendarToggle'); @@ -3582,6 +3639,83 @@ function initializeBoardLabelControls() { }); } + if (duplicateBoardButton) { + duplicateBoardButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + + if ( + duplicateBoardButton.disabled || + !window.chooser || + typeof window.chooser.pickDirectory !== 'function' + ) { + return; + } + + const boardInfo = getBoardRootInfo(); + const defaultPath = boardInfo ? boardInfo.parentRoot.replace(/\/+$/, '') : undefined; + const duplicateName = duplicateBoardInput ? duplicateBoardInput.value : ''; + const sanitizedDuplicateName = sanitizeBoardDirectoryName(duplicateName); + if (!sanitizedDuplicateName) { + if (duplicateBoardStatus) { + duplicateBoardStatus.textContent = 'Name required'; + duplicateBoardStatus.classList.add('is-warning'); + } + return; + } + + duplicateBoardButton.disabled = true; + if (duplicateBoardStatus) { + duplicateBoardStatus.textContent = 'Choose folder'; + duplicateBoardStatus.classList.remove('is-success', 'is-warning'); + } + + try { + const destinationParentSelection = await window.chooser.pickDirectory({ defaultPath }); + const destinationParentDirectory = getDirectorySelectionPath(destinationParentSelection); + if (!destinationParentDirectory) { + if (duplicateBoardStatus) { + duplicateBoardStatus.textContent = ''; + } + return; + } + + if (duplicateBoardStatus) { + duplicateBoardStatus.textContent = 'Duplicating'; + } + const duplicated = await duplicateCurrentBoardDirectory(sanitizedDuplicateName, destinationParentSelection); + if (!duplicated && duplicateBoardStatus) { + duplicateBoardStatus.textContent = 'Unable to duplicate'; + duplicateBoardStatus.classList.add('is-warning'); + } + } catch (error) { + console.error('Unable to duplicate board.', error); + if (duplicateBoardStatus) { + duplicateBoardStatus.textContent = 'Unable to duplicate'; + duplicateBoardStatus.classList.add('is-warning'); + } + if (typeof window !== 'undefined' && typeof window.alert === 'function') { + window.alert(`Unable to duplicate board.\n\n${String(error?.message || error || 'Unknown error')}`); + } + } finally { + duplicateBoardButton.disabled = false; + } + }); + } + + if (duplicateBoardInput) { + duplicateBoardInput.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + if (duplicateBoardButton && !duplicateBoardButton.disabled) { + duplicateBoardButton.click(); + } + }); + } + if (colorSchemeSelect) { colorSchemeSelect.addEventListener('change', async (event) => { const schemeId = event.target.value; @@ -3754,34 +3888,6 @@ function initializeBoardLabelControls() { }); } - if (aiTaskCountInput) { - aiTaskCountInput.addEventListener('change', (event) => { - if (typeof getAppAiSettings !== 'function' || typeof setAppAiSettings !== 'function') { - return; - } - - const currentSettings = getAppAiSettings(); - setAppAiSettings({ - ...currentSettings, - ollama: { - ...currentSettings.ollama, - taskCount: event.target.value, - }, - }); - renderAppSettingsControls(); - scheduleAppSettingsSave(); - }); - - aiTaskCountInput.addEventListener('keydown', (event) => { - if (event.key !== 'Enter') { - return; - } - - event.preventDefault(); - aiTaskCountInput.blur(); - }); - } - if (aiActionsList) { aiActionsList.addEventListener('change', (event) => { const target = event.target; diff --git a/app/cards/createCardElement.js b/app/cards/createCardElement.js index 17b549f..30fd7db 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -251,18 +251,52 @@ function closeCardDatePopoverIfClickOutside(target) { closeCardDatePopover(); } +function getUsableCardDateAnchorRect(anchorElement) { + if ( + !anchorElement || + anchorElement.isConnected === false || + typeof anchorElement.getBoundingClientRect !== 'function' + ) { + return null; + } + + const rect = anchorElement.getBoundingClientRect(); + if ( + !rect || + !Number.isFinite(rect.top) || + !Number.isFinite(rect.left) || + !Number.isFinite(rect.bottom) || + !Number.isFinite(rect.right) || + (rect.width <= 0 && rect.height <= 0) + ) { + return null; + } + + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + }; +} + function positionCardDatePopover(popover, anchorElement) { if ( !popover || - !anchorElement || - typeof anchorElement.getBoundingClientRect !== 'function' || typeof popover.getBoundingClientRect !== 'function' ) { return; } const viewportPadding = 8; - const anchorRect = anchorElement.getBoundingClientRect(); + const anchorRect = getUsableCardDateAnchorRect(anchorElement) || popover.__lastAnchorRect; + if (!anchorRect) { + return; + } + + popover.__lastAnchorRect = anchorRect; const popoverRect = popover.getBoundingClientRect(); const nextWidth = popoverRect.width || 260; let nextLeft = anchorRect.left; @@ -606,7 +640,7 @@ async function createCardElement(cardPath) { dateButton.className = 'metadata-action card-date-action'; const dateIcon = document.createElement('i'); - dateIcon.setAttribute('data-feather', 'clock'); + dateIcon.setAttribute('data-feather', 'calendar'); dateButton.appendChild(dateIcon); const formattedDates = document.createElement('span'); diff --git a/app/modals/toggleEditCardModal.js b/app/modals/toggleEditCardModal.js index 902ff8c..3ade0da 100644 --- a/app/modals/toggleEditCardModal.js +++ b/app/modals/toggleEditCardModal.js @@ -176,6 +176,7 @@ let isApplyingExternalEditorRefresh = false; let cardEditorDropDepth = 0; let cardEditorBodyUrlControlsTeardown = null; let cardEditorSmartActionsRequestId = 0; +const CARD_EDITOR_GENERATED_SUMMARY_MARKER = '---\nEnd generated summary'; const CARD_EDITOR_BODY_URL_PATTERN = /(?:https?:\/\/|www\.)[^\s<>"'`]+/gi; function getActiveEditorCardPath() { @@ -1000,10 +1001,8 @@ function renderCardEditorRelatedNotes(frontmatter = {}) { } async function renderActiveEditorMetadata(frontmatter = {}, timestamps) { - const cardEditorCardStartDateDisplay = document.getElementById('cardEditorCardStartDateDisplay'); - const cardEditorSetStartDateLink = document.getElementById('cardEditorSetStartDateLink'); - const cardEditorCardDueDateDisplay = document.getElementById('cardEditorCardDueDateDisplay'); - const cardEditorSetDueDateLink = document.getElementById('cardEditorSetDueDateLink'); + const cardEditorCardDatesDisplay = document.getElementById('cardEditorCardDatesDisplay'); + const cardEditorSetDatesLink = document.getElementById('cardEditorSetDatesLink'); setEditorLabelDisplay(frontmatter.labels); renderCardEditorRelatedNotes(frontmatter); @@ -1011,26 +1010,35 @@ async function renderActiveEditorMetadata(frontmatter = {}, timestamps) { renderCardEditorTimestamps(timestamps); } - if (cardEditorCardStartDateDisplay) { + if (cardEditorCardDatesDisplay && cardEditorSetDatesLink) { const startValue = String(frontmatter.start || '').trim(); - if (startValue) { - cardEditorCardStartDateDisplay.textContent = await window.board.formatDueDate(startValue); - setDueDateVisualClass(cardEditorSetStartDateLink, startValue); - } else { - cardEditorCardStartDateDisplay.textContent = ''; - setDueDateVisualClass(cardEditorSetStartDateLink, ''); - } - } - - if (cardEditorCardDueDateDisplay) { const dueValue = String(frontmatter.due || '').trim(); - if (dueValue) { - cardEditorCardDueDateDisplay.textContent = await window.board.formatDueDate(dueValue); - setDueDateVisualClass(cardEditorSetDueDateLink, dueValue); + const [startLabel, dueLabel] = await Promise.all([ + startValue ? window.board.formatDueDate(startValue) : '', + dueValue ? window.board.formatDueDate(dueValue) : '', + ]); + + if (startValue && dueValue) { + cardEditorCardDatesDisplay.textContent = typeof getCardDateRangeDisplayLabel === 'function' + ? getCardDateRangeDisplayLabel(startValue, dueValue, startLabel, dueLabel) + : `${startLabel}-${dueLabel}`; + cardEditorSetDatesLink.title = `Dates: ${cardEditorCardDatesDisplay.textContent}`; + cardEditorSetDatesLink.setAttribute('aria-label', `Dates ${startLabel} through ${dueLabel}. Change dates.`); + } else if (startValue) { + cardEditorCardDatesDisplay.textContent = `Starts ${startLabel}`; + cardEditorSetDatesLink.title = `Starts ${startLabel}`; + cardEditorSetDatesLink.setAttribute('aria-label', `Starts ${startLabel}. Change dates.`); + } else if (dueValue) { + cardEditorCardDatesDisplay.textContent = `Due ${dueLabel}`; + cardEditorSetDatesLink.title = `Due ${dueLabel}`; + cardEditorSetDatesLink.setAttribute('aria-label', `Due ${dueLabel}. Change dates.`); } else { - cardEditorCardDueDateDisplay.textContent = ''; - setDueDateVisualClass(cardEditorSetDueDateLink, ''); + cardEditorCardDatesDisplay.textContent = ''; + cardEditorSetDatesLink.title = 'Set dates'; + cardEditorSetDatesLink.setAttribute('aria-label', 'Set dates'); } + + setDueDateVisualClass(cardEditorSetDatesLink, dueValue || startValue || ''); } } @@ -1951,7 +1959,7 @@ function setupCardEditorBodyUrlControls(editor) { }; } -function getTaskLineDueControlIconMarkup(hasDueDate) { +function getTaskLineDateControlIconMarkup() { if ( window.feather && window.feather.icons && @@ -1967,22 +1975,6 @@ function getTaskLineDueControlIconMarkup(hasDueDate) { return '<i data-feather="calendar" aria-hidden="true"></i>'; } -function getTaskLineStartControlIconMarkup(hasStartDate) { - if ( - window.feather && - window.feather.icons && - typeof window.feather.icons['play-circle']?.toSvg === 'function' - ) { - return window.feather.icons['play-circle'].toSvg({ - width: 16, - height: 16, - stroke: 'currentColor', - }); - } - - return '<i data-feather="play-circle" aria-hidden="true"></i>'; -} - function getTaskLineCheckboxIconMarkup(isCompleted) { const iconName = isCompleted ? 'check-square' : 'square'; if ( @@ -2314,39 +2306,58 @@ function setupTaskLineDueDateControls(editor) { layer.appendChild(checkbox); - const startButton = document.createElement('button'); - startButton.type = 'button'; - startButton.className = 'task-line-start-control'; - startButton.dataset.lineIndex = String(taskItem.lineIndex); - startButton.style.top = `${buttonTop}px`; - startButton.style.left = `${Math.round(controlLeft + 20)}px`; + const dateButton = document.createElement('button'); + dateButton.type = 'button'; + dateButton.className = 'task-line-date-control'; + dateButton.dataset.lineIndex = String(taskItem.lineIndex); + dateButton.style.top = `${buttonTop}px`; + dateButton.style.left = `${Math.round(controlLeft + 20)}px`; if (taskItem.start) { - const startLabel = formatLongDueDateLabel(taskItem.start); - startButton.classList.add('has-start'); - startButton.title = `Starts ${startLabel}`; - startButton.setAttribute('aria-label', `Starts ${startLabel}. Change start date.`); + dateButton.classList.add('has-start'); + } + if (taskItem.due) { + dateButton.classList.add('has-due'); + } + + if (taskItem.start && taskItem.due) { + dateButton.title = `Dates: starts ${formatLongDueDateLabel(taskItem.start)}, due ${formatLongDueDateLabel(taskItem.due)}`; + dateButton.setAttribute('aria-label', `${dateButton.title}. Change task dates.`); + } else if (taskItem.start) { + dateButton.title = `Starts ${formatLongDueDateLabel(taskItem.start)}`; + dateButton.setAttribute('aria-label', `${dateButton.title}. Change task dates.`); + } else if (taskItem.due) { + dateButton.title = `Due ${formatLongDueDateLabel(taskItem.due)}`; + dateButton.setAttribute('aria-label', `${dateButton.title}. Change task dates.`); } else { - startButton.title = 'Set task start date'; - startButton.setAttribute('aria-label', 'Set task start date'); + dateButton.title = 'Set task dates'; + dateButton.setAttribute('aria-label', 'Set task dates'); } - startButton.innerHTML = getTaskLineStartControlIconMarkup(Boolean(taskItem.start)); - startButton.addEventListener('click', (event) => { + dateButton.innerHTML = getTaskLineDateControlIconMarkup(); + dateButton.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); - const targetLineIndex = Number(startButton.dataset.lineIndex); - const liveTaskItems = parseTaskListItems(textarea.value); - const liveTaskItem = liveTaskItems.find((item) => item.lineIndex === targetLineIndex); - if (!liveTaskItem) { + const targetLineIndex = Number(dateButton.dataset.lineIndex); + const getLiveTaskItem = () => parseTaskListItems(textarea.value) + .find((item) => item.lineIndex === targetLineIndex); + if (!getLiveTaskItem()) { return; } - openDueDatePickerAtTrigger({ - triggerElement: startButton, - dueDateValue: liveTaskItem.start, - onSelect: async (value) => { + await toggleCardDateSelector({ + anchorElement: dateButton, + getStartDateValue: () => { + const liveTaskItem = getLiveTaskItem(); + return liveTaskItem ? liveTaskItem.start : ''; + }, + getDueDateValue: () => { + const liveTaskItem = getLiveTaskItem(); + return liveTaskItem ? liveTaskItem.due : ''; + }, + formatDateValue: (value) => window.board.formatDueDate(value), + onSelectStart: async (value) => { const nextValue = setTaskListItemStartDateByLineIndex(textarea.value, targetLineIndex, value); if (nextValue === textarea.value) { return; @@ -2355,44 +2366,7 @@ function setupTaskLineDueDateControls(editor) { const caretPosition = getLineEndOffsetByLineIndex(nextValue, targetLineIndex); applyEditorTextareaValuePreservingScroll(textarea, nextValue, caretPosition); }, - }); - }); - - layer.appendChild(startButton); - - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'task-line-due-control'; - button.dataset.lineIndex = String(taskItem.lineIndex); - button.style.top = `${buttonTop}px`; - button.style.left = `${Math.round(controlLeft + 40)}px`; - - if (taskItem.due) { - const dueLabel = formatLongDueDateLabel(taskItem.due); - button.classList.add('has-due'); - button.title = `Due ${dueLabel}`; - button.setAttribute('aria-label', `Due ${dueLabel}. Change due date.`); - } else { - button.title = 'Set task due date'; - button.setAttribute('aria-label', 'Set task due date'); - } - - button.innerHTML = getTaskLineDueControlIconMarkup(Boolean(taskItem.due)); - button.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - - const targetLineIndex = Number(button.dataset.lineIndex); - const liveTaskItems = parseTaskListItems(textarea.value); - const liveTaskItem = liveTaskItems.find((item) => item.lineIndex === targetLineIndex); - if (!liveTaskItem) { - return; - } - - openDueDatePickerAtTrigger({ - triggerElement: button, - dueDateValue: liveTaskItem.due, - onSelect: async (value) => { + onSelectDue: async (value) => { const nextValue = setTaskListItemDueDateByLineIndex(textarea.value, targetLineIndex, value); if (nextValue === textarea.value) { return; @@ -2404,7 +2378,7 @@ function setupTaskLineDueDateControls(editor) { }); }); - layer.appendChild(button); + layer.appendChild(dateButton); } } @@ -2497,33 +2471,20 @@ async function toggleEditCardModal(cardPath, options = {}) { }; const cardEditorTitle = document.getElementById('cardEditorTitle'); - const cardEditorCardStartDateDisplay = document.getElementById('cardEditorCardStartDateDisplay'); - const cardEditorSetStartDateLink = document.getElementById('cardEditorSetStartDateLink'); - const cardEditorCardDueDateDisplay = document.getElementById('cardEditorCardDueDateDisplay'); - const cardEditorSetDueDateLink = document.getElementById('cardEditorSetDueDateLink'); + const cardEditorCardDatesDisplay = document.getElementById('cardEditorCardDatesDisplay'); + const cardEditorSetDatesLink = document.getElementById('cardEditorSetDatesLink'); const cardEditorSetLabelsLink = document.getElementById('cardEditorSetLabelsLink'); setEditorFrontmatter(card.frontmatter); setActiveEditorDiskState(cardPath, card); cardEditorCardPath.value = cardPath; cardEditorTitle.textContent = card.frontmatter.title || ''; - cardEditorCardStartDateDisplay.textContent = ''; - setDueDateVisualClass(cardEditorSetStartDateLink, ''); - cardEditorCardDueDateDisplay.textContent = ''; - setDueDateVisualClass(cardEditorSetDueDateLink, ''); + cardEditorCardDatesDisplay.textContent = ''; + setDueDateVisualClass(cardEditorSetDatesLink, ''); setEditorLabelDisplay(card.frontmatter.labels); renderCardEditorRelatedNotes(card.frontmatter); renderCardEditorTimestamps(card.timestamps); - - if (card.frontmatter.start) { - cardEditorCardStartDateDisplay.textContent = await window.board.formatDueDate(card.frontmatter.start); - setDueDateVisualClass(cardEditorSetStartDateLink, card.frontmatter.start); - } - - if (card.frontmatter.due) { - cardEditorCardDueDateDisplay.textContent = await window.board.formatDueDate(card.frontmatter.due); - setDueDateVisualClass(cardEditorSetDueDateLink, card.frontmatter.due); - } + await renderActiveEditorMetadata(card.frontmatter); const [editor] = new OverType('#cardEditorOverType', { value: card.body, @@ -2564,35 +2525,23 @@ async function toggleEditCardModal(cardPath, options = {}) { await handleNotesSave(cardEditorContents[0].value,false); }; - const openStartDatePickerControl = (e) => { + const openDatesPickerControl = async (e) => { e.preventDefault(); e.stopPropagation(); - const editorFrontmatter = getEditorFrontmatter(); - openDueDatePickerAtTrigger({ - triggerElement: cardEditorSetStartDateLink, - dueDateValue: editorFrontmatter.start, - onSelect: async (value) => { + await toggleCardDateSelector({ + anchorElement: cardEditorSetDatesLink, + getStartDateValue: () => String(getEditorFrontmatter().start || '').trim(), + getDueDateValue: () => String(getEditorFrontmatter().due || '').trim(), + formatDateValue: (value) => window.board.formatDueDate(value), + onSelectStart: async (value) => { await handleMetadataSave(value, 'start'); }, - }); - }; - if (cardEditorSetStartDateLink) { - cardEditorSetStartDateLink.onclick = openStartDatePickerControl; - } - - const openDueDatePickerControl = (e) => { - e.preventDefault(); - e.stopPropagation(); - const editorFrontmatter = getEditorFrontmatter(); - openDueDatePickerAtTrigger({ - triggerElement: cardEditorSetDueDateLink, - dueDateValue: editorFrontmatter.due, - onSelect: async (value) => { + onSelectDue: async (value) => { await handleMetadataSave(value, 'due'); }, }); }; - cardEditorSetDueDateLink.onclick = openDueDatePickerControl; + cardEditorSetDatesLink.onclick = openDatesPickerControl; if (cardEditorSetLabelsLink) { cardEditorSetLabelsLink.onclick = async (e) => { @@ -2686,7 +2635,7 @@ async function toggleEditCardModal(cardPath, options = {}) { setAccessibleModalVisible(modalEditCard, true, { display: 'flex', initialFocus: shouldOpenDueDatePicker - ? '#cardEditorSetDueDateLink' + ? '#cardEditorSetDatesLink' : (shouldFocusNotes ? '#cardEditorOverType .overtype-input' : '#cardEditorTitle'), labelledBy: 'cardEditorTitle', }); @@ -2711,12 +2660,16 @@ async function toggleEditCardModal(cardPath, options = {}) { } if (shouldOpenDueDatePicker) { - cardEditorSetDueDateLink.focus(); - const editorFrontmatter = getEditorFrontmatter(); - openDueDatePickerAtTrigger({ - triggerElement: cardEditorSetDueDateLink, - dueDateValue: editorFrontmatter.due, - onSelect: async (value) => { + cardEditorSetDatesLink.focus(); + await toggleCardDateSelector({ + anchorElement: cardEditorSetDatesLink, + getStartDateValue: () => String(getEditorFrontmatter().start || '').trim(), + getDueDateValue: () => String(getEditorFrontmatter().due || '').trim(), + formatDateValue: (value) => window.board.formatDueDate(value), + onSelectStart: async (value) => { + await handleMetadataSave(value, 'start'); + }, + onSelectDue: async (value) => { await handleMetadataSave(value, 'due'); }, }); @@ -3323,7 +3276,6 @@ function getCardEditorAiSettings() { ollama: { url: 'http://127.0.0.1:11434', model: 'llama3.2', - taskCount: 6, }, smartCardActions: [], }; @@ -3373,6 +3325,18 @@ function getActiveEditorAiLabelNames() { .filter(Boolean); } +function getActiveEditorAiAvailableLabels() { + const labels = typeof getBoardLabels === 'function' ? getBoardLabels() : []; + return Array.isArray(labels) + ? labels + .map((label) => ({ + id: String(label && label.id ? label.id : '').trim(), + name: String(label && label.name ? label.name : '').trim(), + })) + .filter((label) => label.id && label.name) + : []; +} + function getActiveEditorAiContext() { const cardPath = getActiveEditorCardPath(); const listPath = getCardListPath(cardPath); @@ -3380,6 +3344,7 @@ function getActiveEditorAiContext() { const boardName = window.board && typeof window.board.getBoardName === 'function' ? window.board.getBoardName(window.boardRoot || '') : ''; + const frontmatter = getEditorFrontmatter(); return { title: getEditorTitleValue(), @@ -3387,6 +3352,9 @@ function getActiveEditorAiContext() { boardName, listName: getCardEditorListDisplayName(listDirectoryName), labels: getActiveEditorAiLabelNames(), + availableLabels: getActiveEditorAiAvailableLabels(), + start: String(frontmatter.start || '').trim(), + due: String(frontmatter.due || '').trim(), }; } @@ -3438,6 +3406,30 @@ function appendMarkdownToCardEditorBody(body, markdown) { : `${normalizedMarkdown}\n`; } +function prependMarkdownToCardEditorBody(body, markdown) { + const currentBody = String(body || '').replace(/^\s+/g, ''); + const normalizedMarkdown = String(markdown || '').trim(); + if (!normalizedMarkdown) { + return currentBody; + } + + return currentBody + ? `${normalizedMarkdown}\n\n${currentBody}` + : `${normalizedMarkdown}\n`; +} + +function buildCardEditorGeneratedSummaryMarkdown(markdown) { + const normalizedSummary = String(markdown || '') + .replace(/\r\n?/g, '\n') + .trim() + .replace(new RegExp(`\\n{2,}${CARD_EDITOR_GENERATED_SUMMARY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i'), '') + .trim(); + + return normalizedSummary + ? `${normalizedSummary}\n\n${CARD_EDITOR_GENERATED_SUMMARY_MARKER}` + : ''; +} + function setCardEditorBodyValue(nextBody) { if (activeCardEditorInstance && typeof activeCardEditorInstance.setValue === 'function') { activeCardEditorInstance.setValue(nextBody); @@ -3451,6 +3443,24 @@ function setCardEditorBodyValue(nextBody) { } } +async function prependCardEditorSmartMarkdown(markdown, successMessage) { + const normalizedMarkdown = String(markdown || '').trim(); + if (!normalizedMarkdown) { + return; + } + + await flushEditorSaveIfNeeded(); + const nextBody = prependMarkdownToCardEditorBody(getEditorBodyValue(), normalizedMarkdown); + setCardEditorBodyValue(nextBody); + pendingEditorBody = nextBody; + queueEditorSave(nextBody); + await flushEditorSaveIfNeeded(); + + if (successMessage && typeof announceSignboardStatus === 'function') { + announceSignboardStatus(successMessage); + } +} + async function appendCardEditorSmartMarkdown(markdown, successMessage) { const normalizedMarkdown = String(markdown || '').trim(); if (!normalizedMarkdown) { @@ -3469,6 +3479,15 @@ async function appendCardEditorSmartMarkdown(markdown, successMessage) { } } +async function insertCardEditorSmartSummary(markdown) { + const summaryMarkdown = buildCardEditorGeneratedSummaryMarkdown(markdown); + if (!summaryMarkdown) { + return; + } + + await prependCardEditorSmartMarkdown(summaryMarkdown, 'Inserted generated summary.'); +} + async function insertCardEditorSmartTasks(tasks) { const normalizedTasks = normalizeCardEditorSmartTasks(tasks); const checklistMarkdown = buildCardEditorChecklistMarkdown(normalizedTasks); @@ -3502,6 +3521,98 @@ async function replaceCardEditorSmartTitle(title) { } } +function normalizeCardEditorSmartLabelLookupValue(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function getCardEditorCurrentLabelIds() { + const frontmatter = getEditorFrontmatter(); + return Array.isArray(frontmatter.labels) + ? frontmatter.labels.map((labelId) => String(labelId || '').trim()).filter(Boolean) + : []; +} + +function getCardEditorSmartLabelSuggestions(labelReferences) { + const references = Array.isArray(labelReferences) ? labelReferences : []; + const currentLabelIds = new Set(getCardEditorCurrentLabelIds()); + const labels = typeof getBoardLabels === 'function' ? getBoardLabels() : []; + const labelsById = new Map(); + const labelsByName = new Map(); + + for (const label of Array.isArray(labels) ? labels : []) { + const id = String(label && label.id ? label.id : '').trim(); + const name = String(label && label.name ? label.name : '').trim(); + if (!id || !name) { + continue; + } + + labelsById.set(id, label); + labelsByName.set(normalizeCardEditorSmartLabelLookupValue(name), label); + } + + const suggestions = []; + const seenIds = new Set(); + for (const reference of references) { + const idCandidate = String(reference && typeof reference === 'object' ? reference.id : reference || '').trim(); + const nameCandidate = String(reference && typeof reference === 'object' ? reference.name || reference.label || reference.title : reference || '').trim(); + const label = labelsById.get(idCandidate) || labelsByName.get(normalizeCardEditorSmartLabelLookupValue(nameCandidate)); + const labelId = label && label.id ? String(label.id) : ''; + + if (!labelId || currentLabelIds.has(labelId) || seenIds.has(labelId)) { + continue; + } + + seenIds.add(labelId); + suggestions.push(label); + } + + return suggestions; +} + +async function applyCardEditorSmartLabels(labels) { + const suggestedLabels = Array.isArray(labels) ? labels : []; + const currentLabelIds = getCardEditorCurrentLabelIds(); + const nextLabelIds = [...currentLabelIds]; + + for (const label of suggestedLabels) { + const labelId = String(label && label.id ? label.id : '').trim(); + if (labelId && !nextLabelIds.includes(labelId)) { + nextLabelIds.push(labelId); + } + } + + if (nextLabelIds.length === currentLabelIds.length) { + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus('No new labels to add.'); + } + return false; + } + + await flushEditorSaveIfNeeded(); + const normalizedFrontmatter = await window.board.normalizeFrontmatter({ + ...getEditorFrontmatter(), + labels: nextLabelIds, + }); + + setEditorFrontmatter(normalizedFrontmatter); + setEditorLabelDisplay(normalizedFrontmatter.labels); + pendingEditorBody = getEditorBodyValue(); + await enqueueEditorSave(pendingEditorBody); + await renderActiveEditorMetadata(normalizedFrontmatter); + if (typeof renderBoard === 'function') { + await renderBoard(); + } + if (typeof announceSignboardStatus === 'function') { + const addedCount = nextLabelIds.length - currentLabelIds.length; + announceSignboardStatus(`Added ${addedCount} label${addedCount === 1 ? '' : 's'}.`); + } + + return true; +} + function createCardEditorSmartActionButton(label, className, onClick) { const button = document.createElement('button'); button.type = 'button'; @@ -3526,13 +3637,72 @@ function createCardEditorSmartActionButton(label, className, onClick) { return button; } -function renderCardEditorSmartActionsMenu(popover) { - popover.innerHTML = ''; +async function openCardEditorSmartActionsSettingsFromPopover() { + if (!window.boardRoot) { + return; + } + + closeCardEditorSmartActionsPopover(); + closeCardEditorOpenWithPopover(); + closeCardEditorLinkedObjectsPopover(); + if (typeof closeCardDatePopover === 'function') { + closeCardDatePopover(); + } + if (typeof closeBoardMenuPopover === 'function') { + closeBoardMenuPopover(); + } + if (typeof closeBoardViewPopover === 'function') { + closeBoardViewPopover(); + } + if (typeof closeListActionsPopover === 'function') { + closeListActionsPopover(); + } + if (typeof closeAllModals === 'function') { + await closeAllModals({ key: 'Escape' }); + } + if (typeof ensureBoardLabelsLoaded === 'function') { + await ensureBoardLabelsLoaded(); + } + if (typeof openBoardSettingsModal === 'function') { + openBoardSettingsModal({ + panel: 'smart-actions', + initialFocus: '#boardSettingsAiToggle, #boardSettingsAiActionsList input, #boardSettingsNavSmartActions', + }); + } +} + +function createCardEditorSmartActionsMenuHeader() { + const header = document.createElement('div'); + header.className = 'card-editor-smart-actions-header'; const title = document.createElement('p'); title.className = 'card-editor-ai-tasks-title'; title.textContent = 'Smart Card Actions'; - popover.appendChild(title); + + const settingsButton = document.createElement('button'); + settingsButton.type = 'button'; + settingsButton.className = 'card-editor-smart-actions-settings'; + settingsButton.title = 'Open Smart Actions settings'; + settingsButton.setAttribute('aria-label', 'Open Smart Actions settings'); + if (window.feather && window.feather.icons && window.feather.icons.settings) { + settingsButton.innerHTML = window.feather.icons.settings.toSvg(); + } else { + settingsButton.textContent = 'Settings'; + } + settingsButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await openCardEditorSmartActionsSettingsFromPopover(); + }); + + header.append(title, settingsButton); + return header; +} + +function renderCardEditorSmartActionsMenu(popover) { + popover.innerHTML = ''; + + popover.appendChild(createCardEditorSmartActionsMenuHeader()); const menu = document.createElement('div'); menu.className = 'card-editor-smart-actions-menu'; @@ -3547,9 +3717,15 @@ function getCardEditorSmartActionIcon(action) { if (action.type === 'title') { return 'type'; } + if (action.type === 'summary') { + return 'pen-tool'; + } if (action.type === 'tasks') { return 'check-square'; } + if (action.type === 'labels') { + return 'tag'; + } if (action.type === 'paste') { return 'clipboard'; } @@ -3704,6 +3880,96 @@ function renderCardEditorSmartTasksResult(popover, action, result, options = {}) } } +function renderCardEditorSmartSummaryResult(popover, action, result, options = {}) { + const markdown = String(result && result.body ? result.body : '').trim(); + const summaryMarkdown = buildCardEditorGeneratedSummaryMarkdown(markdown); + if (!summaryMarkdown) { + renderCardEditorSmartActionError(popover, 'The model did not return a usable summary.', action, options); + return; + } + + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = action && action.label ? action.label : 'Suggested summary'; + popover.appendChild(title); + + const preview = document.createElement('div'); + preview.className = 'card-editor-smart-action-preview'; + preview.textContent = summaryMarkdown; + popover.appendChild(preview); + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + actions.appendChild(createCardEditorSmartActionButton('Insert at Top', 'card-editor-ai-tasks-primary', async () => { + await insertCardEditorSmartSummary(markdown); + closeCardEditorSmartActionsPopover(); + })); + actions.appendChild(createCardEditorSmartActionButton('Try Again', 'card-editor-ai-tasks-secondary', async () => { + await requestCardEditorSmartAction(popover, action, options); + })); + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + popover.appendChild(actions); +} + +function renderCardEditorSmartLabelsResult(popover, action, result, options = {}) { + const suggestedLabels = getCardEditorSmartLabelSuggestions(result && result.labels); + const availableLabels = getActiveEditorAiAvailableLabels(); + + popover.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'card-editor-ai-tasks-title'; + title.textContent = action && action.label ? action.label : 'Suggested labels'; + popover.appendChild(title); + + if (availableLabels.length === 0) { + const status = document.createElement('p'); + status.className = 'card-editor-ai-tasks-status is-error'; + status.textContent = 'This board does not have any labels to apply.'; + popover.appendChild(status); + } else if (suggestedLabels.length === 0) { + const status = document.createElement('p'); + status.className = 'card-editor-ai-tasks-status'; + status.textContent = 'No new existing labels were suggested for this card.'; + popover.appendChild(status); + } else { + const preview = document.createElement('div'); + preview.className = 'card-editor-smart-action-preview'; + for (const label of suggestedLabels) { + const chip = document.createElement('span'); + chip.className = 'card-label-chip'; + chip.textContent = label.name; + if (typeof getBoardLabelColor === 'function') { + const chipColor = getBoardLabelColor(label); + chip.style.backgroundColor = `${chipColor}22`; + chip.style.borderColor = chipColor; + } + preview.appendChild(chip); + } + popover.appendChild(preview); + } + + const actions = document.createElement('div'); + actions.className = 'card-editor-ai-tasks-actions'; + if (suggestedLabels.length > 0) { + actions.appendChild(createCardEditorSmartActionButton('Apply Labels', 'card-editor-ai-tasks-primary', async () => { + await applyCardEditorSmartLabels(suggestedLabels); + closeCardEditorSmartActionsPopover(); + })); + } + actions.appendChild(createCardEditorSmartActionButton('Try Again', 'card-editor-ai-tasks-secondary', async () => { + await requestCardEditorSmartAction(popover, action, options); + })); + actions.appendChild(createCardEditorSmartActionButton('Back', 'card-editor-ai-tasks-secondary', async () => { + renderCardEditorSmartActionsMenu(popover); + })); + popover.appendChild(actions); +} + function renderCardEditorSmartMarkdownResult(popover, action, result, options = {}) { const markdown = String(result && result.body ? result.body : '').trim(); if (!markdown) { @@ -3742,8 +4008,12 @@ function renderCardEditorSmartActionResult(popover, action, result, options = {} const actionType = String(result && result.actionType ? result.actionType : action.type || ''); if (actionType === 'title') { renderCardEditorSmartTitleResult(popover, action, result, options); + } else if (actionType === 'summary') { + renderCardEditorSmartSummaryResult(popover, action, result, options); } else if (actionType === 'tasks') { renderCardEditorSmartTasksResult(popover, action, result, options); + } else if (actionType === 'labels') { + renderCardEditorSmartLabelsResult(popover, action, result, options); } else { renderCardEditorSmartMarkdownResult(popover, action, result, options); } diff --git a/buildjs.sh b/buildjs.sh index df2a0e8..cad4378 100755 --- a/buildjs.sh +++ b/buildjs.sh @@ -3,6 +3,7 @@ # Concatenate in the required order cat \ + shared/appSettingsSchema.js \ app/utilities/santizeFileName.js \ app/utilities/timestampListItem.js \ app/utilities/dueDateStatus.js \ diff --git a/docs/README.md b/docs/README.md index e40c7b4..e288bab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,9 +20,9 @@ If you want structured tool access from an agent, see [MCP Server](../MCP_README - Creating and organizing boards, lists, and cards - Drag-and-drop movement, including the empty insertion slot shown while dragging cards -- Start/due date ranges on Kanban cards, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting and bulk actions in Table, Planner date views, and External Published Calendar +- Calendar-based start/due date ranges on Kanban cards and in the card editor, task lists, labels, linked-object counts, completed-list workflow settings, Kanban/Table board views, card age sorting and bulk actions in Table, Planner date views, and External Published Calendar - Archiving and restoring cards and lists -- Settings, including app-wide Settings/Notifications/Smart Actions panels and board-specific General, Labels, Appearance, Workflow, Obsidian, and Import panels +- Settings, including app-wide Settings/Notifications/Smart Actions panels, local Ollama Smart Card Actions for generated titles/summaries/task lists/auto-labeling/smart paste, and board-specific General, Labels, Appearance, Workflow, Obsidian, and Import panels, plus board rename/move/duplicate actions - Obsidian integration, including boards stored inside vaults, Open With actions, generated Bases files, linked notes, missing-note handling, linked objects, dropped local-file linking, URL favicons, `signboard://` card/board links, and the optional Obsidian companion plugin - Raw web URLs in card bodies, opened from the editor through the inline open-link control or Cmd/Ctrl-click - Native text editing context menus in editable fields diff --git a/docs/codex/FILE_STRUCTURE.md b/docs/codex/FILE_STRUCTURE.md index c6dd8a1..25e8c20 100644 --- a/docs/codex/FILE_STRUCTURE.md +++ b/docs/codex/FILE_STRUCTURE.md @@ -4,19 +4,19 @@ This map focuses on source and operational files. Large generated/vendor folders ## Top level -- `main.js` - Electron main process window + IPC handlers + trusted board-root/path validation + last-known open-board state persistence for MCP/CLI discovery + filesystem watchers + opt-in localhost External Published Calendar server + native menu/accelerators (including board switcher/settings/theme shortcuts plus required-action validation/rebuild on focus) + optional Quick Add global shortcut registration + Smart Card Action/Ollama model-list IPC + renderer right-click text editing context menu with deferred native popup handling + archive browse/restore + top-of-list card move IPC + linked-object add/open/drop/status/recreate/relink IPC with URL favicon caching + Obsidian outbound/deep-link IPC and `signboard://` protocol dispatch for card and validated board opening + GitHub-release auto-update flow (`electron-updater`), including release-note formatting that strips a `## Downloads` section from in-app update dialogs. +- `main.js` - Electron main process window + IPC handlers + trusted board-root/path validation + last-known open-board state persistence for MCP/CLI discovery + filesystem watchers + opt-in localhost External Published Calendar server + native menu/accelerators (including board switcher/settings/theme shortcuts plus required-action validation/rebuild on focus) + optional Quick Add global shortcut registration + Smart Card Action/Ollama model-list IPC + renderer right-click text editing context menu with deferred native popup handling + archive browse/restore + top-of-list card move IPC + board duplication IPC + linked-object add/open/drop/status/recreate/relink IPC with URL favicon caching + Obsidian outbound/deep-link IPC and `signboard://` protocol dispatch for card and validated board opening + GitHub-release auto-update flow (`electron-updater`), including release-note formatting that strips a `## Downloads` section from in-app update dialogs. - `CODEX.md` - Canonical Codex-specific repo instructions and maintenance rules. - `AGENTS.md` - Cross-tool compatibility entrypoint that points agents to `CODEX.md`. - `DESIGN.md` - Design.md-compatible default theme tokens and visual rationale for Signboard's UI. - `MCP_README.md` - Dedicated setup guide for Signboard MCP server mode (`--mcp-server`). -- `preload.js` - Thin renderer bridge (`window.board`, `window.chooser`, `window.electronAPI`) that forwards allowed operations to main-process IPC and main-process-triggered renderer events, including board switcher/view/settings/Quick Add/signboard-card-link/signboard-board-link events, clipboard text copy, Smart Card Actions, Ollama model inspection, archive browse/read/restore, Obsidian actions, dropped-file path extraction for linked objects, and top-of-list card move calls. +- `preload.js` - Thin renderer bridge (`window.board`, `window.chooser`, `window.electronAPI`) that forwards allowed operations to main-process IPC and main-process-triggered renderer events, including board switcher/view/settings/Quick Add/signboard-card-link/signboard-board-link events, clipboard text copy, Smart Card Actions, Ollama model inspection, archive browse/read/restore, board duplication, Obsidian actions, dropped-file path extraction for linked objects, and top-of-list card move calls. - `index.html` - App shell, header board tab strip, left-edge Planner rail/overlay markup, fixed Sponsor pill, board-menu view/archive/switcher modal markup (including `#boardViewButton`, `#modalKeyboardShortcuts`, `#modalBoardSwitcher`, `#modalArchiveBrowser`, and `#modalObsidianVaultRequired`), and deferred script/style includes. - `readme.md` - Human-facing project README. - `docs/release-template.md` - Curated GitHub release-body template for public download links. - `package.json` - Runtime/build scripts and dependencies. - `package-lock.json` - NPM lockfile. - `.gitignore` - Ignores `node_modules`, `dist`, `.env`, etc. -- `buildjs.sh` - Concatenate renderer modules into `app/signboard.js`. +- `buildjs.sh` - Concatenate shared renderer schema and renderer modules into `app/signboard.js`. - `electron-builder.json` - Build targets/artifact settings. - `LICENSE` - MIT license. - `obsidian-plugin/` - Optional desktop-only Obsidian companion plugin source (`manifest.json`, self-contained `main.js`, helper/tested conversion/link/delete-cleanup utilities, styles, and plugin README) for opening/copying Signboard links, attaching active notes, asking before removing links to deleted notes, creating Signboard boards from folders, and handling `obsidian://signboard?...`. @@ -33,8 +33,8 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/utilities/cardDragTilt.js` - Shared card Sortable fallback options, drag tilt, reduced-motion handling, and drag text-selection lock used by Kanban and temporal card drag/drop. - `app/utilities/cardTimestamps.js` - Renderer card timestamp formatting helpers for editor metadata and Table age columns. - `app/utilities/linkedObjects.js` - Shared renderer helpers for counting structured `linked_objects` and legacy `related` links, plus paperclip count badge creation for Kanban/Table. -- `app/appSettings.js` - Renderer app-settings state, app-wide Settings/Notifications/Smart Actions controls, tooltip/notification/Quick Add global shortcut/AI assistance/External Published Calendar persistence scheduling, and one-time migration from legacy board settings. -- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal app/current-board panel nav, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. +- `app/appSettings.js` - Renderer app-settings state, app-wide Settings/Notifications/Smart Actions controls, tooltip/notification/Quick Add global shortcut/AI assistance/External Published Calendar persistence scheduling, and one-time migration from legacy board settings; shared defaults/normalizers come from `shared/appSettingsSchema.js`. +- `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal app/current-board panel nav, General board rename/move/duplicate controls, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. - `app/board/boardSearch.js` - Board search state, input handling for title/body filtering, and keyboard navigation from the search field through visible card results. - `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card start/due and open task start/due markers, and source-list/source-board pills on temporal cards. - `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table list filter and sort controls, checkbox/shift-range bulk selection, bulk archive/move/label/date actions, and list-column card moves through the top-of-list move IPC path. @@ -53,7 +53,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/modals/toggleAddCardModal.js` - Add-card modal position/toggle. - `app/modals/toggleAddListModal.js` - Add-list modal position/toggle. - `app/modals/toggleAddCardToListModal.js` - Cross-list add-card modal toggle. -- `app/modals/toggleEditCardModal.js` - Card editor open/save/archive/duplicate logic, card start/due metadata controls, Created/Updated timestamp display, active-card top-of-list moves from the dropdown/directional controls, debounced + serialized saves, clean-editor reloads after external/MCP card edits, fresh duplicate lifecycle metadata, raw body URL detection/open controls, linked-object paperclip controls including inline URL/app-link entry, AI checklist suggestion preview/insert flow, Obsidian rename reconciliation between `linked_objects` and `related` wikilinks, missing-note status rendering with recreate/relink/remove actions, drag/drop local-file linking, Open With/Obsidian actions, and task-line start/due date controls aligned from measured line coordinates. +- `app/modals/toggleEditCardModal.js` - Card editor open/save/archive/duplicate logic, compact calendar-based card start/due metadata control, Created/Updated timestamp display, active-card top-of-list moves from the dropdown/directional controls, debounced + serialized saves, clean-editor reloads after external/MCP card edits, fresh duplicate lifecycle metadata, raw body URL detection/open controls, linked-object paperclip controls including inline URL/app-link entry, Smart Card Action previews for title/summary/task-list/auto-label/smart-paste/custom output plus a Smart Actions settings shortcut, Obsidian rename reconciliation between `linked_objects` and `related` wikilinks, missing-note status rendering with recreate/relink/remove actions, drag/drop local-file linking, Open With/Obsidian actions, and one task-line calendar control for start/due dates aligned from measured line coordinates. - `app/listeners/window.js` - Keyboard shortcuts, menu/global-command listeners, Quick Add card modal wiring with board/list selection across open boards, board view switching, Planner toggle/view shortcut handling including all-open-board and current-board date-view scopes, Settings fallback handling, quick board switcher shortcut handling, color cycling, active-card move/archive shortcuts, active-editor closing for workspace-level shortcuts, and the `Cmd/Ctrl + /` helper modal behavior; keep `#modalKeyboardShortcuts` list in sync when adding/changing shortcuts. - `app/init.js` - App bootstrap, folder picker handling, top-level event wiring, Obsidian-vault-required info modal controls, sponsorship modal triggers, and external board-change auto-refresh sync loop, including clean open-editor refreshes. - `app/ui/theme.js` - Theme toggle + OverType theme integration, including the theme shortcut hint/state in the board menu. @@ -61,14 +61,16 @@ This map focuses on source and operational files. Large generated/vendor folders ## Shared/library code +- `shared/appSettingsSchema.js` - Pure app-settings defaults and normalizers shared by the main process and renderer bundle; the single source for built-in Smart Card Action prompts. - `lib/cardFrontmatter.js` - Card parse/normalize/read/write/update with legacy support, including `start` and `due` date normalization. - `lib/cardLifecycle.js` - Shared card lifecycle metadata helper for `createdAt`, compact `activity` trails, archive frontmatter state, and moved/restored transitions. - `lib/cardTimestamps.js` - Shared timestamp resolver for desktop reads, CLI card records/JSON output, and MCP card responses, preferring frontmatter/activity creation data and filesystem modification data. - `lib/cardOrdering.js` - Shared list-card ordering helper used by main-process/MCP restore and move flows to insert a card at the top while renumbering existing files. - `lib/archive.js` - Archive/archive-list filesystem operations plus archive listing/detail/restore helpers and legacy archive fallback handling. - `lib/boardLabels.js` - Board-level label/theme/workflow/External Published Calendar inclusion settings read/write/defaults/filter helpers (`board-settings.md`) plus legacy app-setting extraction for migration. -- `lib/appSettings.js` - App-wide tooltip/notification/Quick Add global shortcut/AI assistance, Smart Card Action prompt, and External Published Calendar settings normalization and JSON persistence under Electron `userData`. -- `lib/aiTaskSuggestions.js` - Ollama `/api/tags` model-list inspection, chat request construction, response parsing, Smart Card Action output parsing, checklist task cleanup, and card-context prompt helpers for Card Editor Smart Card Actions. +- `lib/boardDuplication.js` - Board folder duplication helper used by desktop Settings; copies board contents, assigns fresh copied-card IDs, refreshes copied Signboard metadata, rewrites internal `signboard://open-card` references, rewrites copied local linked-object paths, and resets copied managed Base state. +- `lib/appSettings.js` - App-wide tooltip/notification/Quick Add global shortcut/AI assistance and External Published Calendar JSON persistence under Electron `userData`, delegating defaults and normalization to `shared/appSettingsSchema.js`. +- `lib/aiTaskSuggestions.js` - Ollama `/api/tags` model-list inspection, chat request construction, response parsing, Smart Card Action output parsing including label references, checklist task cleanup, and card-context prompt helpers for Card Editor Smart Card Actions. - `lib/externalPublishedCalendar.js` - External Published Calendar event collection and iCalendar feed generation for card due dates and incomplete task due markers. - `lib/obsidianIntegration.js` - Obsidian URI and Signboard deep-link helpers, flat card property normalization, Obsidian vault discovery, managed generated `Signboard Board.base` files with hash-based user-edit protection, linked-note creation/recreation, and linked-note wikilink resolution. - `lib/importers/index.js` - Export surface for board importers. @@ -87,8 +89,9 @@ This map focuses on source and operational files. Large generated/vendor folders - `scripts/test-frontmatter.js` - Node assertions for frontmatter behavior. - `scripts/test-board-labels.js` - Node assertions for board label settings defaults/migration/filter logic. +- `scripts/test-board-duplication.js` - Node assertions for board folder duplication, copied-card ID refresh, internal Signboard link rewrites, linked-object path rewrites, and copied managed Base reset behavior. - `scripts/test-app-settings.js` - Node assertions for app-wide settings persistence, including AI settings, and one-time board-settings migration. -- `scripts/test-ai-task-suggestions.js` - Node assertions for Ollama model-list/chat request construction, Smart Card Action output parsing, and AI checklist suggestion cleanup without live network calls. +- `scripts/test-ai-task-suggestions.js` - Node assertions for Ollama model-list/chat request construction, Smart Card Action output parsing including label references, and AI checklist suggestion cleanup without live network calls. - `scripts/test-board-card-metadata.js` - Board card metadata rendering assertions (compact start/due ranges, labels, task badge behavior). - `scripts/test-board-views.js` - Kanban/Table/Planner rendering and filter helper assertions. - `scripts/test-card-timestamps.js` - Card timestamp normalization assertions for frontmatter, activity, and filesystem fallback behavior. diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 0624e09..157bf1c 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -30,7 +30,7 @@ File: `main.js` - Registers the `signboard://` protocol in desktop mode, resolves `open-card` links by scanning trusted board roots for matching card IDs, and resolves `open-board` links only for board-looking folders inside an Obsidian vault after prompting before adding a new trusted root. - Builds a native app menu with board view, Settings, theme, and `Check for Updates...` actions, and validates/rebuilds it on focus if required Signboard actions are missing. - Registers the optional app-level Quick Add global shortcut with Electron `globalShortcut` while Signboard is running; the shortcut focuses the main window and opens the same renderer Quick Add card modal as `Cmd/Ctrl + N`. -- Handles opt-in Smart Card Actions by reading app-level Ollama settings and action prompts, verifying/listing local models through Ollama `/api/tags`, and calling the configured Ollama `/api/chat` endpoint through `lib/aiTaskSuggestions.js`. +- Handles opt-in Smart Card Actions by reading app-level Ollama settings and action prompts, verifying/listing local models through Ollama `/api/tags`, and calling the configured Ollama `/api/chat` endpoint through `lib/aiTaskSuggestions.js` for title, summary, task-list, auto-label, smart-paste, and custom actions. - Runs the opt-in External Published Calendar HTTP server on `127.0.0.1:<port>` while enabled, protected by a stable per-install token in the subscription URL. - Help menu includes `Copy MCP Config` to copy a ready-to-paste Signboard MCP JSON snippet. - In unpackaged/dev mode, Help menu includes `Preview Update Available...` and `Preview Update Ready...` to test updater dialogs without downloading/installing. @@ -45,6 +45,7 @@ File: `main.js` - Owns outbound Obsidian operations through `lib/obsidianIntegration.js`: containing-vault detection, Obsidian URI construction, default-app opening, managed generated Bases files, linked note creation, inbox note appends, and Signboard deep-link copying/resolution. - Owns archive browse/read/restore operations through `lib/archive.js`; renderer code never scans or restores archive contents directly. - Owns adjacent-card top-of-list moves through `moveCardToTop`, backed by `lib/cardOrdering.js`. +- Owns board duplication through `lib/boardDuplication.js`; renderer code supplies a tokenized destination folder selection and the main process copies the board, refreshes copied card IDs/metadata, resets copied managed Base state, and trusts the new board root. - In MCP mode, starts `lib/mcpServer.js`, passes desktop trusted board roots plus the last synced desktop open-board state into it, and communicates over stdio using MCP JSON-RPC framing. - MCP stdio transport supports both `Content-Length` framing and newline-delimited JSON-RPC for client compatibility. - Source checkouts also expose a Node CLI at `bin/signboard.js` for direct terminal board/list/card/archive management. @@ -65,17 +66,17 @@ File: `preload.js` - Does not use Node filesystem APIs directly. - Archive browsing uses preload bridge methods (`listArchiveEntries`, `readArchiveEntry`, `restoreArchivedCard`, `restoreArchivedList`) backed by the same trusted-board gate as normal board operations. - Adjacent-card moves from renderer shortcuts use preload method `moveCardToTop`, which validates source/target paths in `main.js` and inserts the card at the top of the target list through `lib/cardOrdering.js`. -- `window.chooser.pickImportSources(...)` returns tokenized external file/directory selections for import flows, and `window.board.importTrello(...)` / `window.board.importObsidian(...)` / `window.board.importTasksMd(...)` invoke the main-process importers. `window.chooser.linkDroppedObjects(...)` extracts OS drag/drop file paths in preload using Electron `webUtils.getPathForFile` and forwards them to main-process linked-object validation. +- `window.chooser.pickImportSources(...)` returns tokenized external file/directory selections for import flows, and `window.board.importTrello(...)` / `window.board.importObsidian(...)` / `window.board.importTasksMd(...)` invoke the main-process importers. Board duplication also uses tokenized directory selections through `window.board.duplicateBoard(...)`. `window.chooser.linkDroppedObjects(...)` extracts OS drag/drop file paths in preload using Electron `webUtils.getPathForFile` and forwards them to main-process linked-object validation. - Still exposes board watch helpers (`startBoardWatch`, `stopBoardWatch`, `getBoardWatchToken`), but the watcher implementation now lives in `main.js`. ### Renderer -Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` +Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**`, shared renderer schema in `shared/appSettingsSchema.js` - UI is vanilla HTML/CSS/JS. - `index.html` loads vendored libraries and `app/signboard.js` with `defer`. - The left-edge Planner rail and overlay markup live in `index.html`; Planner covers the board header/tabs while open and is hidden when no boards are open. -- `app/signboard.js` is concatenated from source modules by `buildjs.sh`. -- Settings groups app-level controls into Settings, Notifications, and Smart Actions panels. The Smart Actions panel owns AI assistance, Ollama verification/model selection, and Smart Card Action prompt customization. Current-board settings are ordered General, Labels, Appearance, Workflow, Obsidian, and Import, with import summary/warning rendering in the existing settings modal. +- `app/signboard.js` is concatenated from source modules and shared renderer schema by `buildjs.sh`. +- Settings groups app-level controls into Settings, Notifications, and Smart Actions panels. The Smart Actions panel owns AI assistance, Ollama verification/model selection, and Smart Card Action prompt customization; generated task-list quantity is controlled by the task-list prompt text rather than a separate app setting. Shared app-settings defaults and normalizers, including built-in Smart Card Action prompts, live in `shared/appSettingsSchema.js` and are consumed by both `lib/appSettings.js` and `app/appSettings.js`. Current-board settings are ordered General, Labels, Appearance, Workflow, Obsidian, and Import; General owns board rename/move/duplicate actions, with import summary/warning rendering in the existing settings modal. - The shared Obsidian-vault-required info modal lives in `index.html` and is controlled from `app/init.js`; linked-note creation and Base generation use it when the active board is not inside a detected vault. - The sponsorship modal is available from the Board menu "Sponsor" item, About modal, and a fixed bottom-right "Sponsor" pill that hides on compact windows so it does not cover lists. - The Board menu now opens a dedicated Archive browser modal; Archive remains hidden from normal board rendering and is not a fourth board view. @@ -235,7 +236,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Quick Add card submissions can target any currently open/trusted board, request opening the created card immediately with the notes field focused, and switch to the target board first when `Shift + Enter` creates a card outside the active board. - `app/modals/toggleEditCardModal.js`: - Loads card into OverType editor. - - Displays and edits card-level start/due dates from frontmatter with separate editor controls. + - Displays and edits card-level start/due dates from frontmatter with one compact calendar-based Dates control and shared two-field popover. - Displays quiet `Created` and `Updated` card timestamps from the normalized desktop read metadata. - Saves title/body/frontmatter through `window.board.writeCard`. - Debounces editor body writes and serializes save order to prevent stale overwrite races. @@ -244,9 +245,9 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**` - Detects raw `http(s)`/`www` URLs in the body without rewriting Markdown, visually marks them in the OverType preview, and opens them through `window.electronAPI.openExternal` from the inline open button or Cmd/Ctrl-click. - Moves active cards to selected/adjacent lists from the list dropdown, arrow action, and keyboard shortcuts by calling the main-process `moveCardToTop` IPC path, which inserts at the top of the destination list. - Defers list-dropdown moves until macOS native menu tracking has settled before disabling controls or refreshing editor state. - - Renders task-line start/due date controls at the start of each parsed checklist line in the editor. + - Renders one task-line calendar control at the start of each parsed checklist line for editing that task's start/due dates. - Uses measured textarea line-start coordinates for control placement so wrapped lines do not drift button positions. - - Handles date pickers, labels picker, linked-object paperclip menu, local-file drag/drop linking, AI checklist item suggestions, duplicate, archive, and Open With actions. + - Handles date pickers, labels picker, linked-object paperclip menu, local-file drag/drop linking, Smart Card Action previews for titles/summaries/task lists/auto-label/smart-paste/custom output, the Smart Actions settings shortcut, duplicate, archive, and Open With actions. - The Open With menu covers default-app file actions and copied Signboard links for every board; Obsidian open/URI actions are shown only when the card is inside a detected vault. - Card duplication now resets archive/lifecycle fields and seeds a fresh `created` event. - `app/utilities/taskList.js`: @@ -499,10 +500,15 @@ CLI overdue behavior: - Script: `scripts/test-app-settings.js` - Covers app-wide tooltip/notification/Quick Add shortcut/AI assistance/External Published Calendar settings persistence and one-time migration from legacy board settings. +### Board duplication tests +- `npm run test:board-duplication` +- Script: `scripts/test-board-duplication.js` +- Covers board folder copying, copied-card ID refresh, internal `signboard://open-card` rewrites, local linked-object path rewrites, and managed Base reset behavior. + ### AI task suggestion tests - `npm run test:ai-task-suggestions` - Script: `scripts/test-ai-task-suggestions.js` -- Covers Ollama `/api/tags` model-list request construction, chat request construction, Smart Card Action response parsing, checklist cleanup, and duplicate removal without requiring a running Ollama instance. +- Covers Ollama `/api/tags` model-list request construction, chat request construction, Smart Card Action response parsing, label-reference parsing, checklist cleanup, and duplicate removal without requiring a running Ollama instance. ### External Published Calendar tests - `npm run test:external-calendar` @@ -526,8 +532,8 @@ CLI overdue behavior: ## Practical Editing Rules for Future Codex Runs -- Prefer editing `app/**` source modules, not `app/signboard.js` directly. -- Rebuild with `./buildjs.sh` whenever `app/**` module files change. +- Prefer editing `app/**` source modules and shared renderer modules such as `shared/appSettingsSchema.js`, not `app/signboard.js` directly. +- Rebuild with `./buildjs.sh` whenever `app/**` or renderer-included `shared/**` module files change. - Always update agent docs (`CODEX.md`, `AGENTS.md`, `docs/codex/PROJECT_CONTEXT.md`, `docs/codex/FILE_STRUCTURE.md`) when behavior/architecture/tooling changes. - Always update release-facing docs (`readme.md`, `docs/README.md`, `docs/using-signboard.md`, `docs/signboard-cli.md`, and `MCP_README.md` when relevant) when user behavior, CLI behavior, or setup flows change. - Keep list/card filename conventions intact; drag/drop logic depends on numeric prefixes. diff --git a/docs/using-signboard.md b/docs/using-signboard.md index 95ce065..d854f20 100644 --- a/docs/using-signboard.md +++ b/docs/using-signboard.md @@ -161,7 +161,7 @@ These features are what make cards show up in Planner and filters. Every card can have a start date in addition to a due date. Use a start date when work is scheduled to begin or become actionable before it is due. -Start dates appear on cards, in Table's `Start` column, in Planner date views, and in date-aware filters. On Kanban cards, start and due dates share one compact `Dates` control. Click the clock icon or date range on a card to open both fields. +Start dates appear on cards, in Table's `Start` column, in Planner date views, and in date-aware filters. On Kanban cards and in the card editor, start and due dates share one compact `Dates` control. Click the calendar icon or date range to open both fields. ### Card due dates @@ -353,7 +353,7 @@ The `App Settings` group controls settings that apply across Signboard: If notifications are enabled, Signboard checks open boards each day at the configured local time and shows a reminder when cards are due. The notification time field is shown only while reminders are enabled. -When AI assistance is enabled, Signboard checks the configured Ollama URL, shows whether it can connect, and loads the locally installed models from Ollama into a model dropdown. Use the refresh button next to the model picker after pulling a new model. The card editor then shows a floating Smart Card Actions button with default actions for generating a new title, generating a task list, and smart paste formatting. App Settings lets you customize each built-in prompt and add custom actions with a label and prompt. Suggestions are previewed before they replace the title or insert Markdown. Card title, body, board/list context, label names, and pasted smart-paste text are sent to the configured Ollama URL only when you use an action. +When AI assistance is enabled, Signboard checks the configured Ollama URL, shows whether it can connect, and loads the locally installed models from Ollama into a model dropdown. Use the refresh button next to the model picker after pulling a new model. The card editor then shows a floating Smart Card Actions button with default actions for generating a new title, generating a summary, generating a task list, auto-labeling from the current board's existing labels, and smart paste formatting. Use the gear in the Smart Card Actions menu to open the Smart Actions settings panel directly. App Settings lets you customize each built-in prompt and add custom actions with a label and prompt; for the generated task list action, change the number in the prompt when you want a different number of tasks. Suggestions are previewed before they replace the title, insert Markdown, or apply labels. Auto-label only applies labels that already exist on the current board, preserves labels already assigned to the card, and skips duplicates. Card title, body, board/list context, start/due dates, current labels, available board labels, and pasted smart-paste text are sent to the configured Ollama URL only when you use an action. ### External Published Calendar @@ -380,6 +380,9 @@ The board `General` section lets you: - rename the board - move the board folder to a new location +- duplicate the board into a chosen folder with a chosen board name + +Duplicating a board copies the board folder, lists, cards, labels, settings, archive contents, and linked objects. Signboard gives copied cards fresh card IDs and updates their `signboard://open-card` links so the new board does not collide with the original. ### Labels diff --git a/index.html b/index.html index 3d816ba..d8c527c 100644 --- a/index.html +++ b/index.html @@ -541,8 +541,6 @@ <h3>Smart Actions</h3> <button id="btnRefreshAiOllamaModels" type="button" title="Refresh Ollama models" aria-label="Refresh Ollama models"><i data-feather="refresh-cw"></i></button> <span id="boardSettingsAiOllamaStatus" class="board-settings-status">Not checked</span> </div> - <label for="boardSettingsAiTaskCount">Suggested task count</label> - <input id="boardSettingsAiTaskCount" type="number" min="3" max="12" step="1" value="6" inputmode="numeric"> <div class="board-settings-ai-actions-header"> <div> <p class="boardSettingsHint boardSettingsHintTight">Smart Card Actions</p> @@ -573,6 +571,15 @@ <h3>Board General</h3> </div> <p class="boardSettingsHint">Move this board folder to another location on your computer.</p> </div> + <div class="board-settings-group"> + <label for="boardSettingsDuplicateNameInput">Duplicate Board name</label> + <div class="board-settings-inline-actions"> + <input id="boardSettingsDuplicateNameInput" type="text" placeholder="Duplicate board name"> + <button id="btnDuplicateBoard" type="button" title="Duplicate board">Duplicate Board</button> + </div> + <p class="boardSettingsHint">Duplicating a board will create a copy of all board files in a new directory of your choice.</p> + <div id="boardSettingsDuplicateStatus" class="board-settings-status board-settings-status-inline" aria-live="polite"></div> + </div> </section> <section id="boardSettingsPanelLabels" class="boardSettingsSection board-settings-panel" role="tabpanel" aria-hidden="true" aria-labelledby="boardSettingsNavLabels" data-settings-panel="labels"> @@ -710,8 +717,7 @@ <h4>Tasks.md</h4> <div> <h2 id="cardEditorTitle" contenteditable="true" role="textbox" aria-label="Card title" aria-multiline="false" spellcheck="true"></h2> <div class="cardEditorMetadataButtons"> - <button type="button" id="cardEditorSetStartDateLink" title="Edit Start Date" aria-label="Edit start date"><i data-feather="play-circle"></i> <span id="cardEditorCardStartDateDisplay" title="This card's start date"></span></button> - <button type="button" id="cardEditorSetDueDateLink" title="Edit Due Date" aria-label="Edit due date"><i data-feather="clock"></i> <span id="cardEditorCardDueDateDisplay" title="This card's due date"></span></button> + <button type="button" id="cardEditorSetDatesLink" title="Edit Dates" aria-label="Edit dates"><i data-feather="calendar"></i> <span id="cardEditorCardDatesDisplay" title="This card's dates"></span></button> <button type="button" id="cardEditorSetLabelsLink" title="Edit Labels" aria-label="Edit labels"><i data-feather="tag"></i> <span id="cardEditorCardLabels" title="This card's labels"></span></button> <button type="button" id="cardEditorLinkedObjectsLink" title="Linked objects" aria-label="Linked objects" aria-expanded="false"><i data-feather="paperclip"></i> <span id="cardEditorLinkedObjectsCount" class="card-editor-linked-objects-count" hidden></span></button> </div> diff --git a/lib/aiTaskSuggestions.js b/lib/aiTaskSuggestions.js index 722506a..db9b0c8 100644 --- a/lib/aiTaskSuggestions.js +++ b/lib/aiTaskSuggestions.js @@ -1,8 +1,10 @@ const DEFAULT_OLLAMA_TIMEOUT_MS = 45000; const DEFAULT_OLLAMA_INSPECT_TIMEOUT_MS = 7000; const DEFAULT_TASK_COUNT = 6; +const DEFAULT_SMART_ACTION_TASK_RESULT_LIMIT = 30; const DEFAULT_TASK_NUM_PREDICT = 768; const DEFAULT_TITLE_NUM_PREDICT = 256; +const DEFAULT_LABEL_NUM_PREDICT = 512; const DEFAULT_SMART_BODY_NUM_PREDICT = 1800; const MAX_BODY_CONTEXT_LENGTH = 6000; const MAX_PASTE_CONTEXT_LENGTH = 12000; @@ -37,7 +39,24 @@ const TASK_TEXT_KEYS = [ 'item', 'label', ]; -const SMART_CARD_ACTION_TYPES = new Set(['title', 'tasks', 'paste', 'custom']); +const LABEL_COLLECTION_KEYS = [ + 'labels', + 'labelNames', + 'label_names', + 'suggestedLabels', + 'suggested_labels', + 'recommendedLabels', + 'recommended_labels', +]; +const LABEL_TEXT_KEYS = [ + 'label', + 'name', + 'title', + 'id', + 'text', + 'value', +]; +const SMART_CARD_ACTION_TYPES = new Set(['title', 'summary', 'tasks', 'labels', 'paste', 'custom']); function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -377,12 +396,108 @@ function extractSuggestedMarkdownBodyFromContent(content) { return normalizeSuggestedMarkdownBody(content); } +function normalizeSuggestedLabelReference(value) { + const normalized = String(value || '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .replace(/^[-*+]\s+/, '') + .replace(/^\d+[.)]\s+/, '') + .replace(/^["'`]+|["'`]+$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + return normalized.length > 120 ? '' : normalized; +} + +function collectSuggestedLabelReferences(value, output, seen, options = {}) { + const maxLabels = Number.isInteger(options.maxLabels) ? options.maxLabels : 20; + if (output.length >= maxLabels || value === null || value === undefined) { + return; + } + + if (typeof value === 'string' || typeof value === 'number') { + const normalized = normalizeSuggestedLabelReference(value); + const key = normalized.toLowerCase(); + if (normalized && !seen.has(key)) { + seen.add(key); + output.push(normalized); + } + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectSuggestedLabelReferences(item, output, seen, options); + if (output.length >= maxLabels) { + return; + } + } + return; + } + + if (isObject(value)) { + for (const key of LABEL_TEXT_KEYS) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + collectSuggestedLabelReferences(value[key], output, seen, options); + return; + } + } + } +} + +function extractSuggestedLabelReferencesFromContent(content, options = {}) { + const parsedValue = parseJsonValue(content); + const output = []; + const seen = new Set(); + const maxLabels = Number.isInteger(options.maxLabels) ? options.maxLabels : 20; + + if (Array.isArray(parsedValue)) { + collectSuggestedLabelReferences(parsedValue, output, seen, { maxLabels }); + return output; + } + + if (isObject(parsedValue)) { + for (const key of LABEL_COLLECTION_KEYS) { + if (Object.prototype.hasOwnProperty.call(parsedValue, key)) { + collectSuggestedLabelReferences(parsedValue[key], output, seen, { maxLabels }); + return output; + } + } + collectSuggestedLabelReferences(parsedValue, output, seen, { maxLabels }); + return output; + } + + if (typeof parsedValue === 'string') { + collectSuggestedLabelReferences(parsedValue, output, seen, { maxLabels }); + return output; + } + + if (parsedValue !== null) { + return []; + } + + const lines = String(content || '') + .replace(/\r\n?/g, '\n') + .split(/[\n,]/) + .map(normalizeSuggestedLabelReference) + .filter(Boolean); + collectSuggestedLabelReferences(lines, output, seen, { maxLabels }); + return output; +} + function normalizeSmartCardAction(actionDefinition = {}) { const source = isObject(actionDefinition) ? actionDefinition : {}; const id = String(source.id || '').trim(); const inferredType = id === 'generate-title' ? 'title' - : (id === 'generate-task-list' ? 'tasks' : (id === 'smart-paste' ? 'paste' : 'custom')); + : (id === 'generate-summary' + ? 'summary' + : (id === 'generate-task-list' + ? 'tasks' + : (id === 'auto-label-card' ? 'labels' : (id === 'smart-paste' ? 'paste' : 'custom')))); const type = SMART_CARD_ACTION_TYPES.has(source.type) ? source.type : inferredType; const label = String(source.label || '').replace(/\s+/g, ' ').trim() || 'Smart Card Action'; const prompt = String(source.prompt || '').replace(/\r\n?/g, '\n').trim(); @@ -405,16 +520,28 @@ function getSmartCardActionNumPredict(action) { return DEFAULT_TASK_NUM_PREDICT; } + if (action.type === 'labels') { + return DEFAULT_LABEL_NUM_PREDICT; + } + return DEFAULT_SMART_BODY_NUM_PREDICT; } -function getSmartCardActionOutputInstruction(action, taskCount) { +function getSmartCardActionOutputInstruction(action) { if (action.type === 'title') { return 'Return JSON only in this shape: {"title":"Improved card title"}.'; } if (action.type === 'tasks') { - return `Return JSON only in this shape: {"tasks":["Task one","Task two"]}. Return ${taskCount} concrete tasks whenever possible.`; + return 'Return JSON only in this shape: {"tasks":["Task one","Task two"]}. Follow the action prompt for how many tasks to create.'; + } + + if (action.type === 'summary') { + return 'Return JSON only in this shape: {"body":"Generated summary Markdown"}. Use only the generated summary text inside the body value; do not include the generated-summary end marker.'; + } + + if (action.type === 'labels') { + return 'Return JSON only in this shape: {"labels":["Exact existing label name"]}. Choose only from availableLabels in the card context. Do not return labels already listed in labels. Return {"labels":[]} when no existing label clearly fits.'; } return 'Return JSON only in this shape: {"body":"Markdown text to add to the card"}. Use Markdown text only inside the body value.'; @@ -422,7 +549,6 @@ function getSmartCardActionOutputInstruction(action, taskCount) { function buildSmartCardActionMessages(actionDefinition = {}, cardContext = {}, options = {}) { const action = normalizeSmartCardAction(actionDefinition); - const taskCount = normalizeTaskCount(options.taskCount, DEFAULT_TASK_COUNT); const existingTasks = Array.isArray(cardContext.existingTasks) ? normalizeSuggestedTaskItems(cardContext.existingTasks, { maxTasks: 30 }) : extractExistingChecklistItems(cardContext.body); @@ -440,10 +566,28 @@ function buildSmartCardActionMessages(actionDefinition = {}, cardContext = {}, o labels: Array.isArray(cardContext.labels) ? cardContext.labels.map((label) => compactText(label, 80)).filter(Boolean).slice(0, 12) : [], + availableLabels: Array.isArray(cardContext.availableLabels) + ? cardContext.availableLabels + .map((label) => { + if (isObject(label)) { + return { + id: compactText(label.id, 80), + name: compactText(label.name, 80), + }; + } + return { + id: '', + name: compactText(label, 80), + }; + }) + .filter((label) => label.id || label.name) + .slice(0, 50) + : [], + start: compactText(cardContext.start, 32), + due: compactText(cardContext.due, 32), existingTasks, body: compactText(cardContext.body, MAX_BODY_CONTEXT_LENGTH), pastedText, - requestedTaskCount: taskCount, currentDate: compactText(options.currentDate, 32), }; @@ -456,7 +600,7 @@ function buildSmartCardActionMessages(actionDefinition = {}, cardContext = {}, o 'Do not duplicate existing checklist items.', 'Do not invent facts.', 'Do not include code fences around the JSON response.', - getSmartCardActionOutputInstruction(action, taskCount), + getSmartCardActionOutputInstruction(action), ].join(' '), }, { @@ -764,7 +908,6 @@ async function runSmartCardActionWithOllama(ollamaSettings = {}, actionDefinitio throw createAiError('Smart Card Action prompt is empty.', 'AI_ACTION_PROMPT_MISSING'); } - const taskCount = normalizeTaskCount(sourceSettings.taskCount, DEFAULT_TASK_COUNT); const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch; if (typeof fetchImpl !== 'function') { throw createAiError('This runtime cannot make AI requests.', 'AI_FETCH_UNAVAILABLE'); @@ -781,7 +924,6 @@ async function runSmartCardActionWithOllama(ollamaSettings = {}, actionDefinitio num_predict: getSmartCardActionNumPredict(action), }, messages: buildSmartCardActionMessages(action, cardContext, { - taskCount, currentDate: options.currentDate, pasteText: options.pasteText, }), @@ -852,7 +994,9 @@ async function runSmartCardActionWithOllama(ollamaSettings = {}, actionDefinitio if (action.type === 'title') { result.title = extractSuggestedTitleFromContent(content); } else if (action.type === 'tasks') { - result.tasks = extractSuggestedTasksFromContent(content, { maxTasks: taskCount }); + result.tasks = extractSuggestedTasksFromContent(content, { maxTasks: DEFAULT_SMART_ACTION_TASK_RESULT_LIMIT }); + } else if (action.type === 'labels') { + result.labels = extractSuggestedLabelReferencesFromContent(content, { maxLabels: 20 }); } else { result.body = extractSuggestedMarkdownBodyFromContent(content); } @@ -860,6 +1004,8 @@ async function runSmartCardActionWithOllama(ollamaSettings = {}, actionDefinitio const hasOutput = Boolean( result.title || (Array.isArray(result.tasks) && result.tasks.length > 0) || + action.type === 'labels' || + (Array.isArray(result.labels) && result.labels.length > 0) || result.body, ); @@ -899,6 +1045,7 @@ async function runSmartCardActionWithOllama(ollamaSettings = {}, actionDefinitio module.exports = { DEFAULT_OLLAMA_INSPECT_TIMEOUT_MS, DEFAULT_OLLAMA_TIMEOUT_MS, + DEFAULT_LABEL_NUM_PREDICT, DEFAULT_TASK_COUNT, DEFAULT_TASK_NUM_PREDICT, DEFAULT_SMART_BODY_NUM_PREDICT, @@ -909,6 +1056,7 @@ module.exports = { buildOllamaTagsUrl, extractExistingChecklistItems, extractSuggestedMarkdownBodyFromContent, + extractSuggestedLabelReferencesFromContent, extractSuggestedTasksFromContent, extractSuggestedTitleFromContent, listOllamaModels, diff --git a/lib/appSettings.js b/lib/appSettings.js index 39b8e4c..d0fb5dd 100644 --- a/lib/appSettings.js +++ b/lib/appSettings.js @@ -1,338 +1,30 @@ const fs = require('fs').promises; const path = require('path'); +const appSettingsSchema = require('../shared/appSettingsSchema'); -const APP_SETTINGS_FILE_NAME = 'app-settings.json'; -const APP_SETTINGS_VERSION = 4; -const DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT = 48273; -const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434'; -const DEFAULT_OLLAMA_MODEL = 'llama3.2'; -const SMART_CARD_ACTION_LABEL_MAX_LENGTH = 80; -const SMART_CARD_ACTION_PROMPT_MAX_LENGTH = 6000; -const CUSTOM_SMART_CARD_ACTION_LIMIT = 12; -const DEFAULT_SMART_CARD_ACTIONS = Object.freeze([ - Object.freeze({ - id: 'generate-title', - type: 'title', - label: 'Generate new title', - prompt: [ - 'Improve the card title using the current title and card body.', - 'Keep it concise, specific, and action-oriented.', - 'Preserve the original intent and do not add facts that are not supported by the card.', - ].join('\n'), - builtIn: true, - }), - Object.freeze({ - id: 'generate-task-list', - type: 'tasks', - label: 'Generate task list', - prompt: [ - 'Generate practical checklist items for this card.', - 'Infer common next actions from the title, body, board, list, and labels.', - 'Do not duplicate existing checklist items.', - 'Use short imperative task text.', - ].join('\n'), - builtIn: true, - }), - Object.freeze({ - id: 'smart-paste', - type: 'paste', - label: 'Smart paste', - prompt: [ - 'Format the pasted information for this Signboard Markdown card.', - 'Preserve the complete useful information from the pasted text, including names, dates, decisions, links, and requirements.', - 'Use clear Markdown sections, a concise summary when helpful, task list items for follow-up work, and reference URLs when present.', - 'Do not invent facts.', - ].join('\n'), - builtIn: true, - }), -]); -const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({ - enabled: false, - time: '09:00', -}); -const DEFAULT_TOOLTIPS_ENABLED = true; -const DEFAULT_QUICK_ADD_SETTINGS = Object.freeze({ - globalShortcut: '', -}); -const DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS = Object.freeze({ - enabled: false, - port: DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, - token: '', -}); -const DEFAULT_AI_SETTINGS = Object.freeze({ - enabled: false, - provider: 'ollama', - ollama: Object.freeze({ - url: DEFAULT_OLLAMA_URL, - model: DEFAULT_OLLAMA_MODEL, - taskCount: 6, - }), - smartCardActions: DEFAULT_SMART_CARD_ACTIONS, -}); -const GLOBAL_SHORTCUT_MAX_LENGTH = 80; - -function isObject(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function normalizeNotificationTime(value) { - const candidate = String(value || '').trim(); - if (/^(?:0[1-9]|1\d|2[0-4]):[0-5]\d$/.test(candidate)) { - return candidate; - } - - return DEFAULT_NOTIFICATION_SETTINGS.time; -} - -function normalizeNotificationSettings(rawNotificationSettings) { - const source = isObject(rawNotificationSettings) ? rawNotificationSettings : {}; - return { - enabled: source.enabled === true, - time: normalizeNotificationTime(source.time), - }; -} - -function normalizeTooltipsEnabled(value) { - return value === false ? false : DEFAULT_TOOLTIPS_ENABLED; -} - -function normalizeGlobalShortcutAccelerator(value) { - const candidate = String(value || '') - .trim() - .replace(/\s*\+\s*/g, '+') - .replace(/\s+/g, ''); - - if (!candidate || candidate.length > GLOBAL_SHORTCUT_MAX_LENGTH) { - return ''; - } - - return candidate; -} - -function normalizeQuickAddSettings(rawQuickAddSettings) { - const source = isObject(rawQuickAddSettings) ? rawQuickAddSettings : {}; - return { - globalShortcut: normalizeGlobalShortcutAccelerator(source.globalShortcut), - }; -} - -function normalizeExternalPublishedCalendarPort(value) { - const parsedPort = Number.parseInt(String(value || ''), 10); - if (Number.isInteger(parsedPort) && parsedPort >= 1024 && parsedPort <= 65535) { - return parsedPort; - } - - return DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS.port; -} - -function normalizeExternalPublishedCalendarToken(value) { - const candidate = String(value || '').trim(); - if (!candidate || candidate.length > 160) { - return ''; - } - - return /^[A-Za-z0-9._~-]+$/.test(candidate) ? candidate : ''; -} - -function normalizeExternalPublishedCalendarSettings(rawCalendarSettings) { - const source = isObject(rawCalendarSettings) ? rawCalendarSettings : {}; - return { - enabled: source.enabled === true, - port: normalizeExternalPublishedCalendarPort(source.port), - token: normalizeExternalPublishedCalendarToken(source.token), - }; -} - -function normalizeAiProvider(value) { - return value === 'ollama' ? 'ollama' : DEFAULT_AI_SETTINGS.provider; -} - -function normalizeOllamaUrl(value) { - let candidate = String(value || '').trim(); - if (!candidate) { - candidate = DEFAULT_AI_SETTINGS.ollama.url; - } - - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { - candidate = `http://${candidate}`; - } - - try { - const parsed = new URL(candidate); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return DEFAULT_AI_SETTINGS.ollama.url; - } - - parsed.username = ''; - parsed.password = ''; - parsed.search = ''; - parsed.hash = ''; - - const basePath = parsed.pathname && parsed.pathname !== '/' - ? parsed.pathname.replace(/\/+$/, '') - : ''; - return `${parsed.origin}${basePath}`; - } catch { - return DEFAULT_AI_SETTINGS.ollama.url; - } -} - -function normalizeOllamaModel(value) { - const candidate = String(value || '').trim(); - if (!candidate || candidate.length > 120 || /[\s\x00-\x1F]/.test(candidate)) { - return DEFAULT_AI_SETTINGS.ollama.model; - } - - return candidate; -} - -function normalizeAiTaskCount(value) { - const parsedCount = Number.parseInt(String(value || ''), 10); - if (Number.isInteger(parsedCount) && parsedCount >= 3 && parsedCount <= 12) { - return parsedCount; - } - - return DEFAULT_AI_SETTINGS.ollama.taskCount; -} - -function cloneDefaultSmartCardActions() { - return DEFAULT_SMART_CARD_ACTIONS.map((action) => ({ ...action })); -} - -function normalizeSmartCardActionLabel(value, fallback = '') { - const candidate = String(value || '') - .replace(/\s+/g, ' ') - .trim(); - const normalizedFallback = String(fallback || '').trim(); - if (!candidate) { - return normalizedFallback; - } - - return candidate.slice(0, SMART_CARD_ACTION_LABEL_MAX_LENGTH).trim() || normalizedFallback; -} - -function normalizeSmartCardActionPrompt(value, fallback = '') { - const candidate = String(value || '') - .replace(/\r\n?/g, '\n') - .trim(); - const normalizedFallback = String(fallback || '').trim(); - if (!candidate) { - return normalizedFallback; - } - - return candidate.slice(0, SMART_CARD_ACTION_PROMPT_MAX_LENGTH).trim() || normalizedFallback; -} - -function normalizeSmartCardActionId(value, fallback = '') { - const candidate = String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80); - const normalizedFallback = String(fallback || '').trim(); - return candidate || normalizedFallback; -} - -function normalizeSmartCardActions(rawActions) { - const sourceActions = Array.isArray(rawActions) ? rawActions : []; - const normalizedActions = []; - const seenIds = new Set(); - - for (const defaultAction of DEFAULT_SMART_CARD_ACTIONS) { - const sourceAction = sourceActions.find((action) => ( - isObject(action) && String(action.id || '') === defaultAction.id - )); - - normalizedActions.push({ - ...defaultAction, - label: defaultAction.label, - prompt: normalizeSmartCardActionPrompt( - sourceAction && Object.prototype.hasOwnProperty.call(sourceAction, 'prompt') - ? sourceAction.prompt - : defaultAction.prompt, - defaultAction.prompt, - ), - builtIn: true, - }); - seenIds.add(defaultAction.id); - } - - let customCount = 0; - for (const action of sourceActions) { - if (!isObject(action) || action.builtIn === true) { - continue; - } - - const label = normalizeSmartCardActionLabel(action.label); - const prompt = normalizeSmartCardActionPrompt(action.prompt); - if (!label || !prompt) { - continue; - } - - const fallbackId = `custom-${customCount + 1}`; - let id = normalizeSmartCardActionId(action.id, fallbackId); - if (DEFAULT_SMART_CARD_ACTIONS.some((defaultAction) => defaultAction.id === id)) { - id = fallbackId; - } - while (seenIds.has(id)) { - id = `custom-${customCount + 1}-${seenIds.size + 1}`; - } - - normalizedActions.push({ - id, - type: 'custom', - label, - prompt, - builtIn: false, - }); - seenIds.add(id); - customCount += 1; - if (customCount >= CUSTOM_SMART_CARD_ACTION_LIMIT) { - break; - } - } - - return normalizedActions; -} - -function normalizeOllamaSettings(rawOllamaSettings) { - const source = isObject(rawOllamaSettings) ? rawOllamaSettings : {}; - return { - url: normalizeOllamaUrl(source.url), - model: normalizeOllamaModel(source.model), - taskCount: normalizeAiTaskCount(source.taskCount), - }; -} - -function normalizeAiSettings(rawAiSettings) { - const source = isObject(rawAiSettings) ? rawAiSettings : {}; - return { - enabled: source.enabled === true, - provider: normalizeAiProvider(source.provider), - ollama: normalizeOllamaSettings(source.ollama), - smartCardActions: normalizeSmartCardActions(source.smartCardActions || source.cardActions), - }; -} - -function normalizeAppSettings(rawSettings = {}) { - const source = isObject(rawSettings) ? rawSettings : {}; - const migration = isObject(source.migration) ? source.migration : {}; - - return { - version: APP_SETTINGS_VERSION, - notifications: normalizeNotificationSettings(source.notifications), - tooltipsEnabled: normalizeTooltipsEnabled(source.tooltipsEnabled), - quickAdd: normalizeQuickAddSettings(source.quickAdd), - externalPublishedCalendar: normalizeExternalPublishedCalendarSettings(source.externalPublishedCalendar), - ai: normalizeAiSettings(source.ai), - migration: { - boardSettingsMigrated: migration.boardSettingsMigrated === true, - sourceBoardRoot: typeof migration.sourceBoardRoot === 'string' ? migration.sourceBoardRoot : '', - migratedAt: typeof migration.migratedAt === 'string' ? migration.migratedAt : '', - }, - }; -} +const { + APP_SETTINGS_FILE_NAME, + DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, + DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS, + DEFAULT_NOTIFICATION_SETTINGS, + DEFAULT_QUICK_ADD_SETTINGS, + DEFAULT_TOOLTIPS_ENABLED, + cloneDefaultAiSettings, + cloneDefaultSmartCardActions, + isObject, + normalizeAiSettings, + normalizeAppSettings, + normalizeExternalPublishedCalendarPort, + normalizeExternalPublishedCalendarSettings, + normalizeGlobalShortcutAccelerator, + normalizeNotificationSettings, + normalizeOllamaModel, + normalizeOllamaSettings, + normalizeOllamaUrl, + normalizeQuickAddSettings, + normalizeSmartCardActions, + normalizeTooltipsEnabled, +} = appSettingsSchema; function getAppSettingsPath(userDataPath) { return path.join(userDataPath, APP_SETTINGS_FILE_NAME); @@ -440,15 +132,10 @@ module.exports = { DEFAULT_QUICK_ADD_SETTINGS: () => ({ ...DEFAULT_QUICK_ADD_SETTINGS }), DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS: () => ({ ...DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS }), DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, - DEFAULT_AI_SETTINGS: () => ({ - ...DEFAULT_AI_SETTINGS, - ollama: { ...DEFAULT_AI_SETTINGS.ollama }, - smartCardActions: cloneDefaultSmartCardActions(), - }), + DEFAULT_AI_SETTINGS: cloneDefaultAiSettings, DEFAULT_SMART_CARD_ACTIONS: cloneDefaultSmartCardActions, normalizeAppSettings, normalizeAiSettings, - normalizeAiTaskCount, normalizeExternalPublishedCalendarPort, normalizeExternalPublishedCalendarSettings, normalizeGlobalShortcutAccelerator, diff --git a/lib/boardDuplication.js b/lib/boardDuplication.js new file mode 100644 index 0000000..28b8697 --- /dev/null +++ b/lib/boardDuplication.js @@ -0,0 +1,300 @@ +const fs = require('fs').promises; +const path = require('path'); +const { randomBytes } = require('crypto'); +const cardFrontmatter = require('./cardFrontmatter'); +const boardLabels = require('./boardLabels'); +const obsidianIntegration = require('./obsidianIntegration'); + +const CARD_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const CARD_FILE_NAME_PATTERN = /^(.*-)([A-Za-z0-9]{5})(\.md)$/; +const SIGNBOARD_CARD_URI_PATTERN = /signboard:\/\/open-card\?([^\s"'<>)]*)/g; +const BOARD_SETTINGS_FILE_NAME = 'board-settings.md'; +const MANAGED_OBSIDIAN_BASE_FILE_NAME = 'Signboard Board.base'; + +function trimString(value) { + return value == null ? '' : String(value).trim(); +} + +function normalizeAbsolutePath(value) { + const normalized = trimString(value); + return normalized ? path.resolve(normalized) : ''; +} + +function isPathInsideRoot(rootPath, targetPath) { + const root = normalizeAbsolutePath(rootPath); + const target = normalizeAbsolutePath(targetPath); + if (!root || !target) { + return false; + } + + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function sanitizeBoardDirectoryName(rawName) { + return trimString(rawName) + .replace(/[\\/:*?"<>|]/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/[ .]+$/g, ''); +} + +function randomCardId(length = 5) { + const bytes = randomBytes(length); + let id = ''; + for (const byte of bytes) { + id += CARD_ID_ALPHABET[byte % CARD_ID_ALPHABET.length]; + } + return id; +} + +function getCardIdFromFileName(fileName) { + const match = trimString(fileName).match(CARD_FILE_NAME_PATTERN); + return match ? match[2] : ''; +} + +function isCardMarkdownPath(boardRoot, filePath) { + const normalizedPath = normalizeAbsolutePath(filePath); + if (!normalizedPath || path.extname(normalizedPath).toLowerCase() !== '.md') { + return false; + } + + const fileName = path.basename(normalizedPath); + if (fileName === BOARD_SETTINGS_FILE_NAME || !CARD_FILE_NAME_PATTERN.test(fileName)) { + return false; + } + + const parentDirectory = path.dirname(normalizedPath); + return parentDirectory !== normalizeAbsolutePath(boardRoot) && isPathInsideRoot(boardRoot, normalizedPath); +} + +async function pathExists(candidatePath) { + try { + await fs.access(candidatePath); + return true; + } catch { + return false; + } +} + +async function walkCardFiles(boardRoot, directoryPath = boardRoot, cardFiles = []) { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + await walkCardFiles(boardRoot, entryPath, cardFiles); + continue; + } + + if (entry.isFile() && isCardMarkdownPath(boardRoot, entryPath)) { + cardFiles.push(entryPath); + } + } + + return cardFiles; +} + +function getUniqueCardId(usedIds) { + let cardId = randomCardId(); + while (usedIds.has(cardId)) { + cardId = randomCardId(); + } + usedIds.add(cardId); + return cardId; +} + +async function renameCopiedCardsWithFreshIds(boardRoot) { + const cardFiles = await walkCardFiles(boardRoot); + const usedIds = new Set(cardFiles.map((filePath) => getCardIdFromFileName(path.basename(filePath))).filter(Boolean)); + const cards = []; + + for (const cardPath of cardFiles) { + const fileName = path.basename(cardPath); + const match = fileName.match(CARD_FILE_NAME_PATTERN); + if (!match) { + continue; + } + + const oldId = match[2]; + let newId = getUniqueCardId(usedIds); + let newFileName = `${match[1]}${newId}${match[3]}`; + let newPath = path.join(path.dirname(cardPath), newFileName); + + while (await pathExists(newPath)) { + usedIds.delete(newId); + newId = getUniqueCardId(usedIds); + newFileName = `${match[1]}${newId}${match[3]}`; + newPath = path.join(path.dirname(cardPath), newFileName); + } + + await fs.rename(cardPath, newPath); + cards.push({ + oldId, + newId, + oldPath: cardPath, + newPath, + }); + } + + return cards; +} + +function rewriteSignboardCardUris(value, idMap) { + const source = trimString(value); + if (!source || !(idMap instanceof Map) || idMap.size === 0) { + return value; + } + + return source.replace(SIGNBOARD_CARD_URI_PATTERN, (match, queryString) => { + const params = new URLSearchParams(queryString); + const currentId = params.get('id') || ''; + const nextId = idMap.get(currentId); + if (!nextId) { + return match; + } + + params.set('id', nextId); + return `signboard://open-card?${params.toString()}`; + }); +} + +function rewritePathInsideCopiedBoard(value, sourceBoardRoot, destinationBoardRoot) { + const candidatePath = normalizeAbsolutePath(value); + if (!candidatePath || !isPathInsideRoot(sourceBoardRoot, candidatePath)) { + return value; + } + + return path.join( + normalizeAbsolutePath(destinationBoardRoot), + path.relative(normalizeAbsolutePath(sourceBoardRoot), candidatePath), + ); +} + +function rewriteLinkedObjects(linkedObjects, context) { + if (!Array.isArray(linkedObjects)) { + return linkedObjects; + } + + return linkedObjects.map((linkedObject) => { + if (!linkedObject || typeof linkedObject !== 'object' || Array.isArray(linkedObject)) { + return linkedObject; + } + + const nextObject = { ...linkedObject }; + if (nextObject.type === 'file' || nextObject.type === 'folder') { + nextObject.path = rewritePathInsideCopiedBoard(nextObject.path, context.sourceBoardRoot, context.destinationBoardRoot); + } + + for (const key of ['url', 'target', 'raw']) { + if (typeof nextObject[key] === 'string') { + nextObject[key] = rewriteSignboardCardUris(nextObject[key], context.idMap); + } + } + + return nextObject; + }); +} + +async function refreshCopiedCardMetadata(boardRoot, cards, context) { + for (const card of cards) { + const currentCard = await cardFrontmatter.readCard(card.newPath); + const rewrittenLinkedObjects = rewriteLinkedObjects(currentCard.frontmatter.linked_objects, context); + const rewrittenBody = rewriteSignboardCardUris(currentCard.body, context.idMap); + const frontmatter = { + ...currentCard.frontmatter, + linked_objects: rewrittenLinkedObjects, + }; + + delete frontmatter.signboard_id; + delete frontmatter.signboard_uri; + + const nextFrontmatter = obsidianIntegration.normalizeSignboardCardFrontmatter({ + boardRoot, + cardPath: card.newPath, + frontmatter, + }); + + await cardFrontmatter.writeCard(card.newPath, { + frontmatter: nextFrontmatter, + body: rewrittenBody, + }); + } +} + +async function resetCopiedManagedObsidianBase(boardRoot) { + const settings = await boardLabels.readBoardSettings(boardRoot, { ensureFile: false }); + if (!settings || !settings.obsidianBase || !settings.obsidianBase.managedHash) { + return false; + } + + await boardLabels.updateBoardSettings(boardRoot, { obsidianBase: {} }); + await fs.rm(path.join(boardRoot, MANAGED_OBSIDIAN_BASE_FILE_NAME), { force: true }); + return true; +} + +async function duplicateBoard(options = {}) { + const sourceBoardRoot = normalizeAbsolutePath(options.sourceBoardRoot); + const destinationParentPath = normalizeAbsolutePath(options.destinationParentPath); + const boardName = sanitizeBoardDirectoryName(options.boardName); + + if (!sourceBoardRoot) { + throw new Error('INVALID_SOURCE_BOARD_ROOT'); + } + if (!destinationParentPath) { + throw new Error('INVALID_DESTINATION_PARENT'); + } + if (!boardName) { + throw new Error('INVALID_BOARD_NAME'); + } + + const sourceStats = await fs.stat(sourceBoardRoot); + if (!sourceStats.isDirectory()) { + throw new Error('SOURCE_BOARD_NOT_DIRECTORY'); + } + + const parentStats = await fs.stat(destinationParentPath); + if (!parentStats.isDirectory()) { + throw new Error('DESTINATION_PARENT_NOT_DIRECTORY'); + } + + const destinationBoardRoot = path.join(destinationParentPath, boardName); + if (normalizeAbsolutePath(destinationBoardRoot) === sourceBoardRoot) { + throw new Error('DESTINATION_MATCHES_SOURCE'); + } + if (isPathInsideRoot(sourceBoardRoot, destinationBoardRoot)) { + throw new Error('DESTINATION_INSIDE_SOURCE'); + } + if (await pathExists(destinationBoardRoot)) { + throw new Error('DESTINATION_ALREADY_EXISTS'); + } + + await fs.cp(sourceBoardRoot, destinationBoardRoot, { + recursive: true, + force: false, + errorOnExist: true, + }); + + const cards = await renameCopiedCardsWithFreshIds(destinationBoardRoot); + const idMap = new Map(cards.map((card) => [card.oldId, card.newId]).filter(([oldId, newId]) => oldId && newId)); + await refreshCopiedCardMetadata(destinationBoardRoot, cards, { + sourceBoardRoot, + destinationBoardRoot, + idMap, + }); + const resetManagedObsidianBase = await resetCopiedManagedObsidianBase(destinationBoardRoot); + + return { + ok: true, + boardRoot: destinationBoardRoot, + boardName, + cardsDuplicated: cards.length, + cardIdMap: Object.fromEntries(idMap.entries()), + resetManagedObsidianBase, + }; +} + +module.exports = { + duplicateBoard, + rewriteSignboardCardUris, + sanitizeBoardDirectoryName, +}; diff --git a/main.js b/main.js index ecd3eba..a20d249 100644 --- a/main.js +++ b/main.js @@ -30,6 +30,7 @@ const appSettings = require('./lib/appSettings'); const { listOllamaModels, runSmartCardActionWithOllama, suggestCardTasksWithOllama } = require('./lib/aiTaskSuggestions'); const { buildExternalPublishedCalendarFeed } = require('./lib/externalPublishedCalendar'); const { importTrello, importObsidian, importTasksMd } = require('./lib/importers'); +const { duplicateBoard } = require('./lib/boardDuplication'); const obsidianIntegration = require('./lib/obsidianIntegration'); const { startSignboardMcpServer } = require('./lib/mcpServer'); const { isCliInvocation, runCli } = require('./lib/cliApp'); @@ -4238,6 +4239,24 @@ ipcMain.handle('board-call', async (event, payload = {}) => { return boardLabels.updateBoardSettings(boardRoot, args[1]); } + case 'duplicateBoard': { + const boardRoot = requireWritableBoardRoot(event.sender, args[0], { allowTrusted: true }); + const options = args[1] && typeof args[1] === 'object' && !Array.isArray(args[1]) ? args[1] : {}; + const destinationParentPath = consumePendingDirectorySelection(event.sender, options.destinationParentToken); + if (!destinationParentPath) { + throw new Error('INVALID_SELECTION_TOKEN'); + } + + const result = await duplicateBoard({ + sourceBoardRoot: boardRoot, + destinationParentPath, + boardName: options.boardName, + }); + addTrustedBoardRoot(result.boardRoot); + await autoSyncManagedObsidianBaseForBoard(result.boardRoot, { refreshMetadata: true }); + return result; + } + case 'createCard': { const filePath = requireWritablePath(event.sender, args[0], { allowTrusted: true }); const content = String(args[1] || ''); diff --git a/package.json b/package.json index a1b0096..f0baf07 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:frontmatter": "node scripts/test-frontmatter.js", "test:card-ordering": "node scripts/test-card-ordering.js", "test:board-labels": "node scripts/test-board-labels.js", + "test:board-duplication": "node scripts/test-board-duplication.js", "test:app-settings": "node scripts/test-app-settings.js", "test:ai-task-suggestions": "node scripts/test-ai-task-suggestions.js", "test:mcp": "node scripts/test-mcp-server.js", diff --git a/preload.js b/preload.js index c612051..c2b6faa 100644 --- a/preload.js +++ b/preload.js @@ -88,6 +88,7 @@ contextBridge.exposeInMainWorld('board', { invokeBoard('updateBoardThemeOverrides', boardRoot, themeOverrides), updateBoardSettings: async (boardRoot, partialSettings) => invokeBoard('updateBoardSettings', boardRoot, partialSettings), + duplicateBoard: async (boardRoot, options) => invokeBoard('duplicateBoard', boardRoot, options), createCard: async (filePath, content, options) => invokeBoard('createCard', filePath, content, options), generateObsidianBase: async (boardRoot) => invokeBoard('generateObsidianBase', boardRoot), openObsidianBase: async (boardRoot) => invokeBoard('openObsidianBase', boardRoot), diff --git a/readme.md b/readme.md index 5c49df9..ecf9a97 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +17,7 @@ Signboard is free for personal use. If you are using Signboard for your work it - ๐Ÿ–Œ๏ธ Color scheme per board (several to choose from!) - ๐ŸŒ™ Light and dark mode variants for all color schemes - ๐Ÿท Custom labels per board -- ๐Ÿ—“ Compact card start/due date ranges and task list item start/due dates +- ๐Ÿ—“ Compact calendar controls for card and task list item start/due dates - ๐Ÿ“‹ Kanban and Table board views, including card age columns, sorting, list filtering, and bulk actions in Table - ๐Ÿ—‚ Planner overlay for actionable dated work across open boards - ๐Ÿ“† Optional local External Published Calendar feed for calendar app subscriptions @@ -27,9 +27,10 @@ Signboard is free for personal use. If you are using Signboard for your work it - โœ… Progress counters on cards - ๐Ÿ”Ž Live search - ๐Ÿ—„๏ธ Linked files and URLs on cards -- โœจ Optional local Ollama Smart Card Actions for titles, task lists, smart paste, and custom prompts +- โœจ Optional local Ollama Smart Card Actions for titles, summaries, task lists, auto-labeling, smart paste, and custom prompts - ๐Ÿงฒ Drag-and-drop card movement - โšก Unlimited open boards with overflow tabs and a quick switcher +- ๐Ÿงฌ Board duplication from Settings with fresh copied-card IDs - โŒจ๏ธ Keyboard shortcuts - โ™ฟ Keyboard, screen reader, reduced-motion, and forced-colors improvements - ๐Ÿค– MCP server diff --git a/scripts/test-ai-task-suggestions.js b/scripts/test-ai-task-suggestions.js index e33d387..42850eb 100644 --- a/scripts/test-ai-task-suggestions.js +++ b/scripts/test-ai-task-suggestions.js @@ -1,12 +1,14 @@ const assert = require('assert'); const { + DEFAULT_LABEL_NUM_PREDICT, DEFAULT_TASK_NUM_PREDICT, DEFAULT_SMART_BODY_NUM_PREDICT, DEFAULT_TITLE_NUM_PREDICT, buildOllamaChatUrl, buildOllamaTagsUrl, extractExistingChecklistItems, + extractSuggestedLabelReferencesFromContent, extractSuggestedMarkdownBodyFromContent, extractSuggestedTasksFromContent, extractSuggestedTitleFromContent, @@ -81,6 +83,10 @@ Here are useful tasks: ]); assert.strictEqual(extractSuggestedTitleFromContent('{"title":"Plan camping meals"}'), 'Plan camping meals'); assert.strictEqual(extractSuggestedMarkdownBodyFromContent('{"body":"## Summary\\n\\nKeep the full details."}'), '## Summary\n\nKeep the full details.'); + assert.deepStrictEqual(extractSuggestedLabelReferencesFromContent('{"labels":["Launch",{"name":"Content"},"Launch"]}'), [ + 'Launch', + 'Content', + ]); let capturedRequest = null; const result = await suggestCardTasksWithOllama({ @@ -203,7 +209,6 @@ Here are useful tasks: const titleActionResult = await runSmartCardActionWithOllama({ url: 'http://127.0.0.1:11434', model: 'llama3.2', - taskCount: 4, }, { id: 'generate-title', type: 'title', @@ -237,7 +242,6 @@ Here are useful tasks: const taskActionResult = await runSmartCardActionWithOllama({ url: 'http://127.0.0.1:11434', model: 'llama3.2', - taskCount: 4, }, { id: 'generate-task-list', type: 'tasks', @@ -262,8 +266,84 @@ Here are useful tasks: }); const taskActionBody = JSON.parse(capturedTaskActionRequest.request.body); assert.strictEqual(taskActionBody.options.num_predict, DEFAULT_TASK_NUM_PREDICT); + assert(taskActionBody.messages[0].content.includes('Follow the action prompt for how many tasks to create.')); + assert(!taskActionBody.messages[1].content.includes('requestedTaskCount')); assert.deepStrictEqual(taskActionResult.tasks, ['Book room', 'Invite team']); + let capturedSummaryActionRequest = null; + const summaryActionResult = await runSmartCardActionWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + }, { + id: 'generate-summary', + type: 'summary', + label: 'Generate card summary', + prompt: 'Summarize the card.', + builtIn: true, + }, { + title: 'Client request', + body: 'Client asked for a Friday delivery. The current plan needs design and QA review.', + }, { + fetchImpl: async (url, request) => { + capturedSummaryActionRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + message: { + content: JSON.stringify({ body: 'Client asked for Friday delivery and needs design plus QA review.' }), + }, + }), + }; + }, + }); + const summaryActionBody = JSON.parse(capturedSummaryActionRequest.request.body); + assert.strictEqual(summaryActionBody.options.num_predict, DEFAULT_SMART_BODY_NUM_PREDICT); + assert(summaryActionBody.messages[0].content.includes('do not include the generated-summary end marker')); + assert.strictEqual(summaryActionResult.actionType, 'summary'); + assert(summaryActionResult.body.includes('Client asked for Friday delivery')); + + let capturedLabelActionRequest = null; + const labelActionResult = await runSmartCardActionWithOllama({ + url: 'http://127.0.0.1:11434', + model: 'llama3.2', + }, { + id: 'auto-label-card', + type: 'labels', + label: 'Auto-label card', + prompt: 'Choose labels.', + builtIn: true, + }, { + title: 'Plan release notes', + body: 'Draft launch copy for the next release.', + labels: ['Launch'], + availableLabels: [ + { id: 'launch', name: 'Launch' }, + { id: 'content', name: 'Content' }, + ], + due: '2026-04-05', + }, { + fetchImpl: async (url, request) => { + capturedLabelActionRequest = { url, request }; + return { + ok: true, + status: 200, + json: async () => ({ + message: { + content: JSON.stringify({ labels: ['Content'] }), + }, + }), + }; + }, + }); + const labelActionBody = JSON.parse(capturedLabelActionRequest.request.body); + assert.strictEqual(labelActionBody.options.num_predict, DEFAULT_LABEL_NUM_PREDICT); + assert(labelActionBody.messages[0].content.includes('Choose only from availableLabels')); + assert(labelActionBody.messages[1].content.includes('"availableLabels"')); + assert(labelActionBody.messages[1].content.includes('"due":"2026-04-05"')); + assert.strictEqual(labelActionResult.actionType, 'labels'); + assert.deepStrictEqual(labelActionResult.labels, ['Content']); + let capturedPasteActionRequest = null; const pasteActionResult = await runSmartCardActionWithOllama({ url: 'http://127.0.0.1:11434', diff --git a/scripts/test-app-settings.js b/scripts/test-app-settings.js index ea74154..9117283 100644 --- a/scripts/test-app-settings.js +++ b/scripts/test-app-settings.js @@ -23,6 +23,8 @@ function assertDefaultSmartCardActions(actions) { builtIn: action.builtIn, }))); assert(actions.every((action) => typeof action.prompt === 'string' && action.prompt.length > 0)); + const taskAction = actions.find((action) => action.id === 'generate-task-list'); + assert(taskAction.prompt.includes('Generate 6 practical checklist items')); } async function run() { @@ -44,7 +46,6 @@ async function run() { ollama: { url: 'http://127.0.0.1:11434', model: 'llama3.2', - taskCount: 6, }, smartCardActions: defaults.ai.smartCardActions, }); @@ -102,16 +103,18 @@ async function run() { ollama: { url: 'http://localhost:11434', model: 'qwen2.5:7b', - taskCount: 8, }, smartCardActions: updated.ai.smartCardActions, }); - assert.strictEqual(updated.ai.smartCardActions.length, 4); + const defaultActionIds = DEFAULT_SMART_CARD_ACTIONS().map((action) => action.id); + assert.strictEqual(updated.ai.smartCardActions.length, defaultActionIds.length + 1); + assert.deepStrictEqual( + updated.ai.smartCardActions.slice(0, defaultActionIds.length).map((action) => action.id), + defaultActionIds, + ); assert.strictEqual(updated.ai.smartCardActions[0].id, 'generate-title'); assert.strictEqual(updated.ai.smartCardActions[0].prompt, 'Custom title prompt'); - assert.strictEqual(updated.ai.smartCardActions[1].id, 'generate-task-list'); - assert.strictEqual(updated.ai.smartCardActions[2].id, 'smart-paste'); - assert.deepStrictEqual(updated.ai.smartCardActions[3], { + assert.deepStrictEqual(updated.ai.smartCardActions[defaultActionIds.length], { id: 'custom-follow-up', type: 'custom', label: 'Draft follow up', @@ -140,7 +143,6 @@ async function run() { ollama: { url: 'http://127.0.0.1:11434', model: 'llama3.2', - taskCount: 6, }, smartCardActions: migrated.settings.ai.smartCardActions, }); diff --git a/scripts/test-board-duplication.js b/scripts/test-board-duplication.js new file mode 100644 index 0000000..ff63b60 --- /dev/null +++ b/scripts/test-board-duplication.js @@ -0,0 +1,133 @@ +const assert = require('assert'); +const fs = require('fs').promises; +const os = require('os'); +const path = require('path'); +const cardFrontmatter = require('../lib/cardFrontmatter'); +const boardLabels = require('../lib/boardLabels'); +const { duplicateBoard } = require('../lib/boardDuplication'); + +async function writeCard(filePath, frontmatter, body) { + await cardFrontmatter.writeCard(filePath, { frontmatter, body }); +} + +async function main() { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-board-duplication-')); + const sourceRoot = path.join(tmpRoot, 'Source Board'); + const destinationParent = path.join(tmpRoot, 'Copies'); + const todoList = path.join(sourceRoot, '001-To-do-stock'); + const doingList = path.join(sourceRoot, '002-Doing-stock'); + const archiveList = path.join(sourceRoot, 'XXX-Archive'); + const assetDirectory = path.join(sourceRoot, 'assets'); + const sourceAssetPath = path.join(assetDirectory, 'reference.txt'); + + await fs.mkdir(todoList, { recursive: true }); + await fs.mkdir(doingList, { recursive: true }); + await fs.mkdir(archiveList, { recursive: true }); + await fs.mkdir(assetDirectory, { recursive: true }); + await fs.mkdir(destinationParent, { recursive: true }); + await fs.writeFile(sourceAssetPath, 'asset', 'utf8'); + + await boardLabels.writeBoardSettings(sourceRoot, { + labels: [{ id: 'label-1', name: 'Release', colorLight: '#0B5FFF', colorDark: '#6FCF97' }], + colorScheme: 'harvest', + obsidianBase: { + managedHash: 'a'.repeat(64), + updatedAt: '2026-06-23T00:00:00.000Z', + }, + }); + await fs.writeFile(path.join(sourceRoot, 'Signboard Board.base'), 'managed base', 'utf8'); + + await writeCard( + path.join(todoList, '000-source-card-Ab123.md'), + { + title: 'Source card', + labels: ['label-1'], + signboard_id: 'Ab123', + signboard_uri: 'signboard://open-card?id=Ab123', + signboard_board: 'Source Board', + signboard_list: 'To-do', + status: 'To-do', + linked_objects: [ + { type: 'file', title: 'Reference', path: sourceAssetPath }, + { type: 'signboard-link', title: 'Doing card', url: 'signboard://open-card?id=Xy789' }, + ], + }, + 'See signboard://open-card?id=Xy789', + ); + await writeCard( + path.join(doingList, '001-doing-card-Xy789.md'), + { + title: 'Doing card', + signboard_id: 'Xy789', + signboard_uri: 'signboard://open-card?id=Xy789', + signboard_board: 'Source Board', + signboard_list: 'Doing', + status: 'Doing', + }, + 'Doing body', + ); + await writeCard( + path.join(archiveList, '002-archived-card-Zz999.md'), + { + title: 'Archived card', + signboard_id: 'Zz999', + signboard_uri: 'signboard://open-card?id=Zz999', + }, + 'Archived body', + ); + + const result = await duplicateBoard({ + sourceBoardRoot: sourceRoot, + destinationParentPath: destinationParent, + boardName: 'Source Board Copy', + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.cardsDuplicated, 3); + assert.strictEqual(result.boardRoot, path.join(destinationParent, 'Source Board Copy')); + assert.strictEqual(await fs.access(path.join(sourceRoot, '001-To-do-stock', '000-source-card-Ab123.md')).then(() => true), true); + + const copiedTodoFiles = await fs.readdir(path.join(result.boardRoot, '001-To-do-stock')); + const copiedSourceFile = copiedTodoFiles.find((fileName) => fileName.startsWith('000-source-card-')); + assert.ok(copiedSourceFile); + assert.notStrictEqual(copiedSourceFile, '000-source-card-Ab123.md'); + + const copiedSourcePath = path.join(result.boardRoot, '001-To-do-stock', copiedSourceFile); + const copiedSourceCard = await cardFrontmatter.readCard(copiedSourcePath); + const copiedDoingId = result.cardIdMap.Xy789; + assert.ok(copiedDoingId); + assert.strictEqual(copiedSourceCard.frontmatter.signboard_board, 'Source Board Copy'); + assert.strictEqual(copiedSourceCard.frontmatter.signboard_list, 'To-do'); + assert.strictEqual(copiedSourceCard.frontmatter.status, 'To-do'); + assert.notStrictEqual(copiedSourceCard.frontmatter.signboard_id, 'Ab123'); + assert.strictEqual( + copiedSourceCard.frontmatter.signboard_uri, + `signboard://open-card?id=${copiedSourceCard.frontmatter.signboard_id}`, + ); + assert.strictEqual( + copiedSourceCard.frontmatter.linked_objects[0].path, + path.join(result.boardRoot, 'assets', 'reference.txt'), + ); + assert.strictEqual( + copiedSourceCard.frontmatter.linked_objects[1].url, + `signboard://open-card?id=${copiedDoingId}`, + ); + assert.strictEqual(copiedSourceCard.body, `See signboard://open-card?id=${copiedDoingId}`); + + const copiedSettings = await boardLabels.readBoardSettings(result.boardRoot, { ensureFile: false }); + assert.deepStrictEqual(copiedSettings.obsidianBase, { managedHash: '', updatedAt: '' }); + await assert.rejects( + fs.access(path.join(result.boardRoot, 'Signboard Board.base')), + /ENOENT/, + ); + + const archivedFiles = await fs.readdir(path.join(result.boardRoot, 'XXX-Archive')); + assert.ok(archivedFiles.some((fileName) => fileName.startsWith('002-archived-card-') && fileName !== '002-archived-card-Zz999.md')); + + await fs.rm(tmpRoot, { recursive: true, force: true }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/shared/appSettingsSchema.js b/shared/appSettingsSchema.js new file mode 100644 index 0000000..22ba0b1 --- /dev/null +++ b/shared/appSettingsSchema.js @@ -0,0 +1,403 @@ +(function initializeAppSettingsSchema(root, factory) { + const schema = factory(); + if (typeof window === 'undefined' && typeof module === 'object' && module.exports) { + module.exports = schema; + } + if (root) { + root.SignboardAppSettingsSchema = schema; + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function createAppSettingsSchema() { + const APP_SETTINGS_FILE_NAME = 'app-settings.json'; + const APP_SETTINGS_VERSION = 5; + const DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT = 48273; + const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434'; + const DEFAULT_OLLAMA_MODEL = 'llama3.2'; + const SMART_CARD_ACTION_LABEL_MAX_LENGTH = 80; + const SMART_CARD_ACTION_PROMPT_MAX_LENGTH = 6000; + const CUSTOM_SMART_CARD_ACTION_LIMIT = 12; + const GLOBAL_SHORTCUT_MAX_LENGTH = 80; + const DEFAULT_SMART_CARD_ACTIONS = Object.freeze([ + Object.freeze({ + id: 'generate-title', + type: 'title', + label: 'Generate new card title', + prompt: [ + 'Improve the card title using the current title and card body as inspiration.', + 'Keep it concise, specific, and action-oriented.', + 'Preserve the original intent and do not add facts that are not supported by the card.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'generate-summary', + type: 'summary', + label: 'Generate card summary', + prompt: [ + 'Inspect the card title and body contents and write a summary.', + 'Keep the summary a short paragraph or two at most (preferably one).', + 'Do not be overly detailed but make sure the summary is informative.', + 'Do not invent facts.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'generate-task-list', + type: 'tasks', + label: 'Generate task list', + prompt: [ + 'Generate 6 practical checklist items for this card.', + 'Infer common tasks that would help complete the goal of the card from the title, body, board, list, and labels.', + 'Do not duplicate existing checklist items.', + 'Use short imperative task text.', + 'Do not include checkbox prefixes.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'auto-label-card', + type: 'labels', + label: 'Auto-label card', + prompt: [ + 'Choose labels for this card from the existing board labels only.', + 'Use the card title, body, list, current labels, start date, due date, and available board labels.', + 'Do not suggest labels that are already assigned to the card.', + 'Do not invent new labels.', + 'Return only labels that clearly fit the card.', + 'Return no labels if none of the existing labels clearly fit.', + ].join('\n'), + builtIn: true, + }), + Object.freeze({ + id: 'smart-paste', + type: 'paste', + label: 'Smart paste', + prompt: [ + 'Format the pasted information for this Signboard Markdown card.', + 'Preserve the complete useful information from the pasted text, including names, dates, decisions, links, and requirements.', + 'Use clear Markdown sections, a concise summary when helpful, task list items for follow-up work, and reference URLs when present.', + 'Do not invent facts.', + ].join('\n'), + builtIn: true, + }), + ]); + const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({ + enabled: false, + time: '09:00', + }); + const DEFAULT_TOOLTIPS_ENABLED = true; + const DEFAULT_QUICK_ADD_SETTINGS = Object.freeze({ + globalShortcut: '', + }); + const DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS = Object.freeze({ + enabled: false, + port: DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, + token: '', + }); + const DEFAULT_AI_SETTINGS = Object.freeze({ + enabled: false, + provider: 'ollama', + ollama: Object.freeze({ + url: DEFAULT_OLLAMA_URL, + model: DEFAULT_OLLAMA_MODEL, + }), + smartCardActions: DEFAULT_SMART_CARD_ACTIONS, + }); + + function isObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cloneDefaultSmartCardActions() { + return DEFAULT_SMART_CARD_ACTIONS.map((action) => ({ ...action })); + } + + function cloneDefaultAiSettings() { + return { + ...DEFAULT_AI_SETTINGS, + ollama: { ...DEFAULT_AI_SETTINGS.ollama }, + smartCardActions: cloneDefaultSmartCardActions(), + }; + } + + function normalizeNotificationTime(value) { + const candidate = String(value || '').trim(); + if (/^(?:0[1-9]|1\d|2[0-4]):[0-5]\d$/.test(candidate)) { + return candidate; + } + + return DEFAULT_NOTIFICATION_SETTINGS.time; + } + + function normalizeNotificationSettings(rawNotificationSettings) { + const source = isObject(rawNotificationSettings) ? rawNotificationSettings : {}; + return { + enabled: source.enabled === true, + time: normalizeNotificationTime(source.time), + }; + } + + function normalizeTooltipsEnabled(value) { + return value === false ? false : DEFAULT_TOOLTIPS_ENABLED; + } + + function normalizeGlobalShortcutAccelerator(value) { + const candidate = String(value || '') + .trim() + .replace(/\s*\+\s*/g, '+') + .replace(/\s+/g, ''); + + if (!candidate || candidate.length > GLOBAL_SHORTCUT_MAX_LENGTH) { + return ''; + } + + return candidate; + } + + function normalizeQuickAddSettings(rawQuickAddSettings) { + const source = isObject(rawQuickAddSettings) ? rawQuickAddSettings : {}; + return { + globalShortcut: normalizeGlobalShortcutAccelerator(source.globalShortcut), + }; + } + + function normalizeExternalPublishedCalendarPort(value) { + const parsedPort = Number.parseInt(String(value || ''), 10); + if (Number.isInteger(parsedPort) && parsedPort >= 1024 && parsedPort <= 65535) { + return parsedPort; + } + + return DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS.port; + } + + function normalizeExternalPublishedCalendarToken(value) { + const candidate = String(value || '').trim(); + if (!candidate || candidate.length > 160) { + return ''; + } + + return /^[A-Za-z0-9._~-]+$/.test(candidate) ? candidate : ''; + } + + function normalizeExternalPublishedCalendarSettings(rawCalendarSettings) { + const source = isObject(rawCalendarSettings) ? rawCalendarSettings : {}; + return { + enabled: source.enabled === true, + port: normalizeExternalPublishedCalendarPort(source.port), + token: normalizeExternalPublishedCalendarToken(source.token), + }; + } + + function normalizeAiProvider(value) { + return value === 'ollama' ? 'ollama' : DEFAULT_AI_SETTINGS.provider; + } + + function normalizeOllamaUrl(value) { + let candidate = String(value || '').trim(); + if (!candidate) { + candidate = DEFAULT_AI_SETTINGS.ollama.url; + } + + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { + candidate = `http://${candidate}`; + } + + try { + const parsed = new URL(candidate); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return DEFAULT_AI_SETTINGS.ollama.url; + } + + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + + const basePath = parsed.pathname && parsed.pathname !== '/' + ? parsed.pathname.replace(/\/+$/, '') + : ''; + return `${parsed.origin}${basePath}`; + } catch { + return DEFAULT_AI_SETTINGS.ollama.url; + } + } + + function normalizeOllamaModel(value) { + const candidate = String(value || '').trim(); + if (!candidate || candidate.length > 120 || /[\s\x00-\x1F]/.test(candidate)) { + return DEFAULT_AI_SETTINGS.ollama.model; + } + + return candidate; + } + + function normalizeSmartCardActionLabel(value, fallback = '') { + const candidate = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + const normalizedFallback = String(fallback || '').trim(); + if (!candidate) { + return normalizedFallback; + } + + return candidate.slice(0, SMART_CARD_ACTION_LABEL_MAX_LENGTH).trim() || normalizedFallback; + } + + function normalizeSmartCardActionPrompt(value, fallback = '') { + const candidate = String(value || '') + .replace(/\r\n?/g, '\n') + .trim(); + const normalizedFallback = String(fallback || '').trim(); + if (!candidate) { + return normalizedFallback; + } + + return candidate.slice(0, SMART_CARD_ACTION_PROMPT_MAX_LENGTH).trim() || normalizedFallback; + } + + function normalizeSmartCardActionId(value, fallback = '') { + const candidate = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + const normalizedFallback = String(fallback || '').trim(); + return candidate || normalizedFallback; + } + + function normalizeSmartCardActions(rawActions) { + const sourceActions = Array.isArray(rawActions) ? rawActions : []; + const normalizedActions = []; + const seenIds = new Set(); + + for (const defaultAction of DEFAULT_SMART_CARD_ACTIONS) { + const sourceAction = sourceActions.find((action) => ( + isObject(action) && String(action.id || '') === defaultAction.id + )); + + normalizedActions.push({ + ...defaultAction, + label: defaultAction.label, + prompt: normalizeSmartCardActionPrompt( + sourceAction && Object.prototype.hasOwnProperty.call(sourceAction, 'prompt') + ? sourceAction.prompt + : defaultAction.prompt, + defaultAction.prompt, + ), + builtIn: true, + }); + seenIds.add(defaultAction.id); + } + + let customCount = 0; + for (const action of sourceActions) { + if (!isObject(action) || action.builtIn === true) { + continue; + } + + const label = normalizeSmartCardActionLabel(action.label); + const prompt = normalizeSmartCardActionPrompt(action.prompt); + if (!label || !prompt) { + continue; + } + + const fallbackId = `custom-${customCount + 1}`; + let id = normalizeSmartCardActionId(action.id, fallbackId); + if (DEFAULT_SMART_CARD_ACTIONS.some((defaultAction) => defaultAction.id === id)) { + id = fallbackId; + } + while (seenIds.has(id)) { + id = `custom-${customCount + 1}-${seenIds.size + 1}`; + } + + normalizedActions.push({ + id, + type: 'custom', + label, + prompt, + builtIn: false, + }); + seenIds.add(id); + customCount += 1; + if (customCount >= CUSTOM_SMART_CARD_ACTION_LIMIT) { + break; + } + } + + return normalizedActions; + } + + function normalizeOllamaSettings(rawOllamaSettings) { + const source = isObject(rawOllamaSettings) ? rawOllamaSettings : {}; + return { + url: normalizeOllamaUrl(source.url), + model: normalizeOllamaModel(source.model), + }; + } + + function normalizeAiSettings(rawAiSettings) { + const source = isObject(rawAiSettings) ? rawAiSettings : {}; + return { + enabled: source.enabled === true, + provider: normalizeAiProvider(source.provider), + ollama: normalizeOllamaSettings(source.ollama), + smartCardActions: normalizeSmartCardActions(source.smartCardActions || source.cardActions), + }; + } + + function normalizeAppSettings(rawSettings = {}) { + const source = isObject(rawSettings) ? rawSettings : {}; + const migration = isObject(source.migration) ? source.migration : {}; + + return { + version: APP_SETTINGS_VERSION, + notifications: normalizeNotificationSettings(source.notifications), + tooltipsEnabled: normalizeTooltipsEnabled(source.tooltipsEnabled), + quickAdd: normalizeQuickAddSettings(source.quickAdd), + externalPublishedCalendar: normalizeExternalPublishedCalendarSettings(source.externalPublishedCalendar), + ai: normalizeAiSettings(source.ai), + migration: { + boardSettingsMigrated: migration.boardSettingsMigrated === true, + sourceBoardRoot: typeof migration.sourceBoardRoot === 'string' ? migration.sourceBoardRoot : '', + migratedAt: typeof migration.migratedAt === 'string' ? migration.migratedAt : '', + }, + }; + } + + return { + APP_SETTINGS_FILE_NAME, + APP_SETTINGS_VERSION, + DEFAULT_AI_SETTINGS, + DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_PORT, + DEFAULT_EXTERNAL_PUBLISHED_CALENDAR_SETTINGS, + DEFAULT_NOTIFICATION_SETTINGS, + DEFAULT_QUICK_ADD_SETTINGS, + DEFAULT_SMART_CARD_ACTIONS, + DEFAULT_TOOLTIPS_ENABLED, + CUSTOM_SMART_CARD_ACTION_LIMIT, + GLOBAL_SHORTCUT_MAX_LENGTH, + SMART_CARD_ACTION_LABEL_MAX_LENGTH, + SMART_CARD_ACTION_PROMPT_MAX_LENGTH, + cloneDefaultAiSettings, + cloneDefaultSmartCardActions, + isObject, + normalizeAiProvider, + normalizeAiSettings, + normalizeAppSettings, + normalizeExternalPublishedCalendarPort, + normalizeExternalPublishedCalendarSettings, + normalizeExternalPublishedCalendarToken, + normalizeGlobalShortcutAccelerator, + normalizeNotificationSettings, + normalizeNotificationTime, + normalizeOllamaModel, + normalizeOllamaSettings, + normalizeOllamaUrl, + normalizeQuickAddSettings, + normalizeSmartCardActionId, + normalizeSmartCardActionLabel, + normalizeSmartCardActionPrompt, + normalizeSmartCardActions, + normalizeTooltipsEnabled, + }; +}); diff --git a/static/styles.css b/static/styles.css index 0f8657c..36ef5f0 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3776,6 +3776,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, } #modalEditCard { + --card-editor-body-inset: 49px; position: fixed; z-index: 50; top: max(64px, calc(env(safe-area-inset-top) + 14px)); @@ -3820,7 +3821,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, } #modalEditCard #cardEditorTitle { - padding-left: 16px; + padding-left: var(--card-editor-body-inset); } #modalBoardSettings { @@ -4042,6 +4043,12 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, text-align: right; } +.board-settings-status-inline { + min-height: 20px; + margin-top: 8px; + text-align: left; +} + .board-settings-status.is-success { color: var(--task-progress-complete-color); } @@ -4093,7 +4100,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, align-items: flex-start; justify-content: space-between; gap: 12px; - margin-top: 4px; + margin-top: 40px; } .board-settings-ai-actions-header button { @@ -4162,11 +4169,6 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, height: 16px; } -#boardSettingsAiTaskCount { - width: 8ch; - min-width: 8ch; -} - .board-settings-toggle-row { border: 1px solid var(--border); border-radius: var(--radius); @@ -4915,15 +4917,11 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, #modalEditCard .cardEditorMetadataButtons button:active { transform: none; } -#modalEditCard #cardEditorCardStartDateDisplay, -#modalEditCard #cardEditorCardDueDateDisplay { font-size: var(--font-sm); color: var(--muted); } -#modalEditCard #cardEditorSetStartDateLink, -#modalEditCard #cardEditorSetDueDateLink, +#modalEditCard #cardEditorCardDatesDisplay { font-size: var(--font-sm); color: var(--muted); } +#modalEditCard #cardEditorSetDatesLink, #modalEditCard #cardEditorSetLabelsLink { text-decoration: none; } -#modalEditCard .cardEditorMetadataButtons #cardEditorSetStartDateLink.due-date-today, -#modalEditCard .cardEditorMetadataButtons #cardEditorSetDueDateLink.due-date-today { color: var(--due-date-today-color); } -#modalEditCard .cardEditorMetadataButtons #cardEditorSetStartDateLink.due-date-tomorrow, -#modalEditCard .cardEditorMetadataButtons #cardEditorSetDueDateLink.due-date-tomorrow { color: var(--due-date-tomorrow-color); } +#modalEditCard .cardEditorMetadataButtons #cardEditorSetDatesLink.due-date-today { color: var(--due-date-today-color); } +#modalEditCard .cardEditorMetadataButtons #cardEditorSetDatesLink.due-date-tomorrow { color: var(--due-date-tomorrow-color); } #modalEditCard .cardEditorHeader { border-bottom: 1px solid var(--border); @@ -5016,6 +5014,40 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, font-weight: 700; line-height: 1.2; } +.card-editor-smart-actions-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + margin: 0 0 8px; + padding: 0 2px; +} +.card-editor-smart-actions-header .card-editor-ai-tasks-title { + margin: 0; +} +.card-editor-smart-actions-settings { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + box-shadow: none; +} +.card-editor-smart-actions-settings:hover { + border-color: var(--border); + color: var(--text); +} +.card-editor-smart-actions-settings svg { + width: 15px; + height: 15px; +} .card-editor-ai-tasks-status { margin: 0; color: var(--muted); @@ -5197,7 +5229,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, align-items: center; flex-wrap: wrap; gap: 12px; - padding-left: 16px; + padding-left: var(--card-editor-body-inset); } #modalEditCard .cardEditorMetadataButtons a, #modalEditCard .cardEditorMetadataButtons button { @@ -5223,8 +5255,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, #modalEditCard .card-editor-linked-objects-count[hidden] { display: none; } -#modalEditCard #cardEditorCardStartDateDisplay, -#modalEditCard #cardEditorCardDueDateDisplay, +#modalEditCard #cardEditorCardDatesDisplay, #modalEditCard #cardEditorCardLabels { margin-right: 0; } #modalEditCard #cardEditorCardLabels.card-editor-labels { display: inline-flex; @@ -5241,7 +5272,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, align-items: center; flex-wrap: wrap; gap: 6px; - padding: 6px 16px 0; + padding: 6px 16px 0 var(--card-editor-body-inset); } #modalEditCard .card-editor-related-notes[hidden] { display: none; @@ -5341,7 +5372,7 @@ button:focus-visible { outline: 3px solid color-mix(in oklab, var(--accent) 45%, flex-wrap: wrap; gap: 4px 12px; min-height: 28px; - padding: 7px 16px 0; + padding: 7px 16px 0 var(--card-editor-body-inset); color: color-mix(in oklab, var(--muted) 92%, var(--text)); font-size: 11px; line-height: 1.3; @@ -5608,12 +5639,12 @@ select:disabled { } #cardEditorOverType .overtype-toolbar { - padding-left: 16px !important; + padding-left: var(--card-editor-body-inset) !important; } #cardEditorOverType .overtype-input, #cardEditorOverType .overtype-preview { - padding-left: 49px !important; + padding-left: var(--card-editor-body-inset) !important; } #cardEditorOverType .task-line-due-layer { @@ -5623,8 +5654,7 @@ select:disabled { z-index: 6; } -#cardEditorOverType .task-line-start-control, -#cardEditorOverType .task-line-due-control { +#cardEditorOverType .task-line-date-control { position: absolute; display: inline-flex; align-items: center; @@ -5641,21 +5671,19 @@ select:disabled { cursor: pointer; } -#cardEditorOverType .task-line-start-control:hover, -#cardEditorOverType .task-line-due-control:hover { +#cardEditorOverType .task-line-date-control:hover { border-color: color-mix(in oklab, var(--accent) 48%, var(--border)); color: color-mix(in oklab, var(--accent) 78%, var(--text)); } -#cardEditorOverType .task-line-start-control.has-start, -#cardEditorOverType .task-line-due-control.has-due { +#cardEditorOverType .task-line-date-control.has-start, +#cardEditorOverType .task-line-date-control.has-due { border-color: color-mix(in oklab, var(--accent) 40%, var(--border)); background: color-mix(in oklab, var(--accent) 12%, var(--bg-card)); color: color-mix(in oklab, var(--accent) 80%, var(--text)); } -#cardEditorOverType .task-line-start-control svg, -#cardEditorOverType .task-line-due-control svg { +#cardEditorOverType .task-line-date-control svg { width: 14px; height: 14px; display: block; diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index 37eccca..561190b 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -7,10 +7,14 @@ const electronBinary = require('electron'); const { test: base, expect, _electron: electron } = require('@playwright/test'); const { createFixtureBoard, createFixtureBoardAt } = require('./helpers/fixtureBoard'); const cardFrontmatter = require('../../lib/cardFrontmatter'); +const appSettingsSchema = require('../../shared/appSettingsSchema'); const repoRoot = path.resolve(__dirname, '../..'); const usesMetaModifier = process.platform === 'darwin'; const shouldBringPlaywrightAppToFront = process.env.SIGNBOARD_PLAYWRIGHT_FOREGROUND === '1'; +const defaultSmartCardActionLabels = appSettingsSchema + .cloneDefaultSmartCardActions() + .map((action) => action.label); function normalizeBoardRoot(boardRoot) { const normalized = String(boardRoot || '').replace(/\\/g, '/').trim(); @@ -2725,22 +2729,55 @@ test('closes the task due date picker when clearing a task due date', async ({ p await openFirstCardInEditor(page); await setEditorBody(page, '- [ ] (due: 2026-04-20) Follow up with beta testers'); - await expect(page.locator('#cardEditorOverType .task-line-due-control.has-due')).toHaveCount(1); + await expect(page.locator('#cardEditorOverType .task-line-date-control.has-due')).toHaveCount(1); - await page.locator('#cardEditorOverType .task-line-due-control.has-due').click(); + await page.locator('#cardEditorOverType .task-line-date-control.has-due').click(); + await expect(page.locator('.card-date-popover')).toBeVisible(); + await page.locator('.card-date-popover-row[data-field="due"] .card-date-popover-field').click(); const datepickerPopup = page.locator('.sb-themed-fdatepicker'); await expect(datepickerPopup).toBeVisible(); await datepickerPopup.getByRole('button', { name: 'Clear' }).click(); await expect(datepickerPopup).toBeHidden(); - await expect(page.locator('#cardEditorOverType .task-line-due-control.has-due')).toHaveCount(0); + await expect(page.locator('#cardEditorOverType .task-line-date-control.has-due')).toHaveCount(0); await expect(page.locator('#cardEditorOverType .overtype-input')).toHaveValue(/^- \[ \] Follow up with beta testers$/); - await page.locator('#cardEditorOverType .task-line-due-control').click(); + await page.locator('#cardEditorOverType .task-line-date-control').click(); + await expect(page.locator('.card-date-popover')).toBeVisible(); + await page.locator('.card-date-popover-row[data-field="due"] .card-date-popover-field').click(); await expect(datepickerPopup).toBeVisible(); }); +test('keeps the task date popover anchored after choosing a task start date', async ({ page }) => { + await openFirstCardInEditor(page); + + await setEditorBody(page, '- [ ] This task needs a start date'); + const dateButton = page.locator('#cardEditorOverType .task-line-date-control').first(); + await expect(dateButton).toBeVisible(); + + await dateButton.click(); + const datePopover = page.locator('.card-date-popover'); + await expect(datePopover).toBeVisible(); + const initialPopoverBox = await datePopover.boundingBox(); + expect(initialPopoverBox).toBeTruthy(); + + await page.locator('.card-date-popover-row[data-field="start"] .card-date-popover-field').click(); + const datepickerPopup = page.locator('.sb-themed-fdatepicker'); + await expect(datepickerPopup).toBeVisible(); + await datepickerPopup.getByRole('button', { name: 'Today' }).click(); + + await expect(datepickerPopup).toBeHidden(); + await expect(page.locator('#cardEditorOverType .overtype-input')).toHaveValue(/\(start: \d{4}-\d{2}-\d{2}\)/); + await expect(datePopover).toBeVisible(); + await page.waitForTimeout(100); + + const finalPopoverBox = await datePopover.boundingBox(); + expect(finalPopoverBox).toBeTruthy(); + expect(Math.abs(finalPopoverBox.x - initialPopoverBox.x)).toBeLessThan(12); + expect(Math.abs(finalPopoverBox.y - initialPopoverBox.y)).toBeLessThan(12); +}); + test('opens raw card body URLs without rewriting the body', async ({ page }) => { await openFirstCardInEditor(page); @@ -2893,7 +2930,6 @@ test('persists AI assistance settings and shows the card editor Smart Card Actio ...current.ai.ollama, url: ollamaUrl, model: 'llama3.2', - taskCount: 6, }, }, }); @@ -2914,7 +2950,6 @@ test('persists AI assistance settings and shows the card editor Smart Card Actio const aiModelSelect = page.locator('#boardSettingsAiOllamaModel'); const aiRefreshButton = page.locator('#btnRefreshAiOllamaModels'); const aiStatus = page.locator('#boardSettingsAiOllamaStatus'); - const aiTaskCountInput = page.locator('#boardSettingsAiTaskCount'); await expect(aiToggle).not.toBeChecked(); await expect(aiDetails).toBeHidden(); @@ -2928,10 +2963,9 @@ test('persists AI assistance settings and shows the card editor Smart Card Actio await expect(aiStatus).toContainText(/Connected/); await expect(aiModelSelect).toContainText('qwen2.5:7b'); await expect(aiModelSelect).toContainText('llama3.2:latest'); + await expect(page.locator('#boardSettingsAiTaskCount')).toHaveCount(0); await aiModelSelect.selectOption('qwen2.5:7b'); - await aiTaskCountInput.fill('8'); - await aiTaskCountInput.blur(); await aiRefreshButton.click(); await expect(aiStatus).toContainText(/Connected/); @@ -2951,13 +2985,8 @@ test('persists AI assistance settings and shows the card editor Smart Card Actio ollama: { url: fakeOllama.url, model: 'qwen2.5:7b', - taskCount: 8, }, - actionLabels: [ - 'Generate new title', - 'Generate task list', - 'Smart paste', - ], + actionLabels: defaultSmartCardActionLabels, }); await page.locator('#boardSettingsClose').click(); @@ -2985,14 +3014,80 @@ test('persists AI assistance settings and shows the card editor Smart Card Actio popoverZIndex: 12020, popoverHidden: false, }); - await expect(smartActionsPopover).toContainText('Generate new title'); - await expect(smartActionsPopover).toContainText('Generate task list'); - await expect(smartActionsPopover).toContainText('Smart paste'); + for (const label of defaultSmartCardActionLabels) { + await expect(smartActionsPopover).toContainText(label); + } + + const summaryIconClass = await page.evaluate(() => { + const button = Array.from(document.querySelectorAll('.card-editor-smart-action-button')) + .find((element) => element.textContent.includes('Generate card summary')); + const icon = button ? button.querySelector('svg') : null; + return icon ? String(icon.getAttribute('class') || '') : ''; + }); + expect(summaryIconClass).toContain('feather-pen-tool'); + + const settingsShortcut = smartActionsPopover.getByRole('button', { name: 'Open Smart Actions settings' }); + await expect(settingsShortcut).toBeVisible(); + await settingsShortcut.click(); + await expect(page.locator('#modalEditCard')).toBeHidden(); + await expect(page.locator('#modalBoardSettings')).toBeVisible(); + await expect(page.locator('#boardSettingsNavSmartActions')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#boardSettingsPanelSmartActions')).toHaveClass(/is-active/); } finally { await fakeOllama.close(); } }); +test('inserts generated Smart Card Action summaries at the top of the card body', async ({ page }) => { + await openFirstCardInEditor(page); + + await setEditorBody(page, 'Existing card notes.\n\n- [ ] Keep this task'); + await page.evaluate(async () => { + await window.insertCardEditorSmartSummary('Generated overview.\n\nSecond sentence.\n\n---\nEnd generated summary'); + }); + + await expect(page.locator('#cardEditorOverType .overtype-input')).toHaveValue([ + 'Generated overview.', + '', + 'Second sentence.', + '', + '---', + 'End generated summary', + '', + 'Existing card notes.', + '', + '- [ ] Keep this task', + ].join('\n')); +}); + +test('merges auto-label Smart Card Action suggestions with existing card labels', async ({ page }) => { + await openFirstCardInEditor(page); + + const result = await page.evaluate(async () => { + const suggestions = window.getCardEditorSmartLabelSuggestions([ + 'Launch', + 'Content', + 'Missing Label', + 'content', + ]); + const applied = await window.applyCardEditorSmartLabels(suggestions); + const cardPath = document.getElementById('cardEditorCardPath').value; + const card = await window.board.readCard(cardPath); + return { + suggestedNames: suggestions.map((label) => label.name), + applied, + editorLabels: window.getEditorFrontmatter().labels, + diskLabels: card.frontmatter.labels, + }; + }); + + expect(result.suggestedNames).toEqual(['Content']); + expect(result.applied).toBe(true); + expect(result.editorLabels.sort()).toEqual(['content', 'launch']); + expect(result.diskLabels.sort()).toEqual(['content', 'launch']); + await expect(page.locator('#cardEditorCardLabels .card-label-chip')).toContainText(['Launch', 'Content']); +}); + test('publishes the External Published Calendar and respects board opt-out', async ({ page, boardRoot, request }) => { const calendarPort = await getAvailableLoopbackPort(); await cardFrontmatter.updateFrontmatter( From bb86423603269cd887ef0e8da206342aa1384c2e Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Tue, 23 Jun 2026 10:25:16 -0400 Subject: [PATCH 07/10] Better file handling reliability and speed improvements. --- AGENTS.md | 3 + CODEX.md | 4 + app/board/boardViews.js | 49 +++++-- app/board/plannerView.js | 15 +- app/board/renderBoard.js | 65 +++++---- app/board/tableView.js | 32 +++-- app/cards/createCardElement.js | 27 +++- app/lists/createListElement.js | 79 ++++------- buildjs.sh | 1 + docs/codex/FILE_STRUCTURE.md | 10 +- docs/codex/PROJECT_CONTEXT.md | 20 ++- lib/appSettings.js | 3 +- lib/archive.js | 3 +- lib/boardLabels.js | 3 +- lib/cardFrontmatter.js | 3 +- lib/cardOrdering.js | 172 +++++++++++++++++++++++ lib/cliState.js | 3 +- lib/obsidianIntegration.js | 11 +- main.js | 72 +++++++++- package.json | 1 + preload.js | 4 + scripts/test-board-views.js | 1 + scripts/test-card-ordering.js | 102 +++++++++++++- tests/playwright/signboard-smoke.spec.js | 45 +++++- 24 files changed, 584 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8259efa..555ffd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Read [docs/codex/FILE_STRUCTURE.md](./docs/codex/FILE_STRUCTURE.md) for the repository map. - Read [DESIGN.md](./DESIGN.md) before changing the default theme, visual system, or UI component styling. - Treat `app/signboard.js` as generated output; edit source modules in `app/**` or shared renderer modules such as `shared/appSettingsSchema.js`, then run `./buildjs.sh`. +- Use `lib/atomicFile.js` for durable writes to Signboard-managed card, settings, sidecar, and Obsidian integration files. - Keep keyboard shortcut behavior, `index.html` shortcut help, and user-facing shortcut docs in sync. - Keep modal focus handling, live status announcements, reduced-motion behavior, forced-colors behavior, and keyboard-only focus styling in sync with `app/utilities/accessibility.js` and `static/styles.css`. - Mark body-level popovers that must remain interactive while a modal is active with `data-sb-modal-layer`, so the modal background inert handler does not disable them. @@ -18,10 +19,12 @@ If you are an agent or tool that looks for `AGENTS.md`, use `CODEX.md` as the ca - Keep native app menu actions and the Playwright native-menu regression aligned when changing menu behavior. - Defer DOM/layout mutations after macOS native menu or `<select>` popup interactions with `waitForNativeMenuTrackingToSettle()` before replacing, disabling, rerendering, or moving controls. - Keep card drag/drop clone and placeholder behavior in sync with `app/utilities/cardDragTilt.js` and `static/styles.css`. +- Keep card/list drag/drop filesystem ordering on the main-process transactional helpers in `lib/cardOrdering.js`; do not move reorder loops back into renderer code. - MCP allowed roots include both explicit MCP roots and desktop trusted board roots; keep root loading, `get_config`, `signboard_list_boards`, and board-name resolution tests aligned. - CLI and MCP board discovery/creation should stay aligned: `signboard boards list` and `signboard_list_boards` report the same known-board metadata shape where applicable, while `signboard boards create` and `signboard_create_board` scaffold the same default lists and starter card. - Quick board switching uses `Cmd/Ctrl + K`, searches all currently open boards, and should keep tab switching, overflow tab switching, and switcher switching on the same safe board-switch helper. - Planner is the workspace-level home for Calendar, This Week, Day, and Agenda, including all-open-board and current-board shortcut scopes; keep `app/board/plannerView.js`, Kanban/Table board rendering, Table bulk-selection behavior, search/filter behavior, and shortcut docs aligned. +- Normal board rendering uses `readBoardSnapshot` and `app/board/boardSnapshot.js` to batch list/card reads for Kanban, Table, and Planner; keep snapshot shape and consumers aligned. - Workspace-level keyboard shortcuts close the active card editor before changing context; editor-scoped card move/archive shortcuts should keep acting on the open card. - External board-change sync should refresh board cards and unchanged open card editors after MCP/card-file edits without overwriting local editor changes. - Card timestamp UI and automation surfaces use `timestamps.createdAt` and `timestamps.updatedAt`; keep editor timestamp display, Table age columns/sorting, CLI age sorts, and MCP card responses aligned with the shared timestamp helpers. diff --git a/CODEX.md b/CODEX.md index 0b04f1a..a35a30a 100644 --- a/CODEX.md +++ b/CODEX.md @@ -23,9 +23,12 @@ Start here before opening source files. - `main.js` supports `--mcp-config` to print a ready-to-paste MCP config JSON snippet and exit. - `Help` menu includes `Copy MCP Config`, which copies a ready-to-paste MCP server config snippet to clipboard. - `preload.js` is now a thin IPC bridge only; board filesystem access, trusted-board validation, and filesystem watch helpers live in `main.js`, while `app/init.js` still uses the same watch methods to auto-refresh after external board changes and refresh an unchanged open card editor after external/MCP card edits. +- Core file-backed writes use `lib/atomicFile.js` so cards, board settings, app settings, archive sidecars, CLI state, and Obsidian-managed files are written through same-directory temp files before atomic replacement. - Archive browsing and restore now run through `lib/archive.js` via `main.js` / `preload.js`; the renderer opens a dedicated Archive modal (`app/board/archiveBrowser.js`) instead of treating Archive as a board view. - Active-card adjacent-list moves use the main-process `moveCardToTop` IPC path backed by `lib/cardOrdering.js`, so renderer shortcuts do not perform multi-step filesystem renames directly. +- Card and list drag/drop reorder use main-process `reorderCardsInList` / `reorderLists` IPC paths backed by transactional helpers in `lib/cardOrdering.js`; do not reintroduce renderer-side multi-rename loops. - Board rendering supports Kanban by default plus a board-scoped Table view for scanning and bulk-managing cards; Calendar, This Week, Day, and Agenda dated workflows live in Planner. +- Board render, Table, and Planner use the batched `readBoardSnapshot` IPC path plus the renderer adapter in `app/board/boardSnapshot.js` to avoid per-card IPC reads during normal view rendering. - Planner is managed in `app/board/plannerView.js`; it appears as a left-edge overlay only when at least one board tab is open, defaults to all open boards, has a quick current-board scope toggle, and owns Calendar, This Week, Day, and Agenda views. - Planner search/filtering is separate from board search: it searches card title/body plus board/list source text, filters by `Today` / `Overdue` / `Next 7 days` / `Next 14 days` / `Next 30 days`, completed-card visibility, and selected open boards, and exposes label filtering only when scoped to the active board. - App-level settings live in `app-settings.json` under Electron `userData` via `lib/appSettings.js`; shared defaults/normalizers, including built-in Smart Card Action prompts, live in `shared/appSettingsSchema.js`. Tooltip and notification preferences migrate once from the left-most open board's legacy `board-settings.md` values and are no longer serialized into board settings, while the optional Quick Add global shortcut, AI assistance/Ollama settings, and External Published Calendar settings are stored only in app settings. @@ -61,6 +64,7 @@ Start here before opening source files. - External Published Calendar coverage is in `scripts/test-external-published-calendar.js` (`npm run test:external-calendar`). - Due notification coverage tests are in `scripts/test-due-notifications.js` (`npm run test:due-notifications`). - Board duplication coverage is in `scripts/test-board-duplication.js` (`npm run test:board-duplication`). +- Board snapshot coverage is in `scripts/test-board-snapshot.js` (`npm run test:board-snapshot`). - Dedicated user-facing MCP setup docs are in `MCP_README.md`. - Release-facing user and agent docs live in `docs/README.md`, `docs/using-signboard.md`, and `docs/signboard-cli.md`. - Reusable agent skill for MCP usage lives at `skills/signboard-mcp/SKILL.md`. diff --git a/app/board/boardViews.js b/app/board/boardViews.js index 41c975a..7f5f9d6 100644 --- a/app/board/boardViews.js +++ b/app/board/boardViews.js @@ -579,9 +579,17 @@ async function collectCardsForCalendar(boardRoot, lists, options = {}) { : String(boardRoot || ''); const listEntries = await Promise.all( - listNames.map(async (listName) => { - const listPath = `${boardRoot}${listName}`; - const cardNames = await window.board.listCards(listPath); + listNames.map(async (listEntry) => { + const isSnapshotList = listEntry && typeof listEntry === 'object' && !Array.isArray(listEntry); + const listName = isSnapshotList + ? String(listEntry.listName || '').trim() + : String(listEntry || '').trim(); + const listPath = isSnapshotList + ? String(listEntry.listPath || (typeof joinBoardSnapshotPath === 'function' ? joinBoardSnapshotPath(boardRoot, listName) : `${boardRoot}${listName}`)).trim() + : `${boardRoot}${listName}`; + const cardNames = isSnapshotList && Array.isArray(listEntry.cards) + ? listEntry.cards + : await window.board.listCards(listPath); return { listName, listDisplayName: getBoardListDisplayName(listName), @@ -595,24 +603,31 @@ async function collectCardsForCalendar(boardRoot, lists, options = {}) { ); for (const { listName, listDisplayName, listPath, cardNames, isCompletedList } of listEntries) { - for (const cardName of cardNames) { + for (const cardItem of cardNames) { + const cardPath = typeof getBoardSnapshotCardPath === 'function' + ? getBoardSnapshotCardPath(listPath, cardItem) + : `${listPath}/${cardItem}`; cardPaths.push({ listName, listDisplayName, - cardPath: `${listPath}/${cardName}`, + cardPath, + cardRecord: cardItem, isCompletedList, }); } } const cardEntries = await Promise.all( - cardPaths.map(async ({ listName, listDisplayName, cardPath, isCompletedList }) => { - const card = await window.board.readCard(cardPath); + cardPaths.map(async ({ listName, listDisplayName, cardPath, cardRecord, isCompletedList }) => { + const snapshotCard = typeof getBoardSnapshotCardData === 'function' + ? getBoardSnapshotCardData(cardRecord) + : null; + const card = snapshotCard || await window.board.readCard(cardPath); const frontmatter = card && card.frontmatter && typeof card.frontmatter === 'object' ? card.frontmatter : {}; const body = String(card && card.body ? card.body : ''); - const taskItems = parseTaskListItems(body); + const taskItems = Array.isArray(card.taskItems) ? card.taskItems : parseTaskListItems(body); return { boardRoot: normalizedBoardRoot, @@ -630,12 +645,20 @@ async function collectCardsForCalendar(boardRoot, lists, options = {}) { ? frontmatter.labels.map((labelId) => String(labelId)) : [], body, - taskSummary: getTaskListSummary(body), + taskSummary: card.taskSummary && typeof card.taskSummary === 'object' + ? card.taskSummary + : getTaskListSummary(body), taskItems, - taskStartDates: typeof getTaskListStartDates === 'function' ? getTaskListStartDates(body) : [], - incompleteTaskStartDates: typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(body) : [], - taskDueDates: getTaskListDueDates(body), - incompleteTaskDueDates: getIncompleteTaskListDueDates(body), + taskStartDates: Array.isArray(card.taskStartDates) + ? card.taskStartDates + : (typeof getTaskListStartDates === 'function' ? getTaskListStartDates(body) : []), + incompleteTaskStartDates: Array.isArray(card.incompleteTaskStartDates) + ? card.incompleteTaskStartDates + : (typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(body) : []), + taskDueDates: Array.isArray(card.taskDueDates) ? card.taskDueDates : getTaskListDueDates(body), + incompleteTaskDueDates: Array.isArray(card.incompleteTaskDueDates) + ? card.incompleteTaskDueDates + : getIncompleteTaskListDueDates(body), }; }), ); diff --git a/app/board/plannerView.js b/app/board/plannerView.js index 29d80ad..2b5ce31 100644 --- a/app/board/plannerView.js +++ b/app/board/plannerView.js @@ -851,13 +851,14 @@ async function collectPlannerCardsForBoard(boardRoot) { const fallbackBoardName = getBoardLabelFromPath(normalizedBoardRoot); try { - const [boardDisplayName, lists, boardSettings] = await Promise.all([ - Promise.resolve(window.board.getBoardName(normalizedBoardRoot)).catch(() => fallbackBoardName), - window.board.listLists(normalizedBoardRoot), - typeof window.board.readBoardSettings === 'function' - ? window.board.readBoardSettings(normalizedBoardRoot).catch(() => ({})) - : Promise.resolve({}), - ]); + const snapshot = await readBoardSnapshotForRender(normalizedBoardRoot, { + includeBoardSettings: true, + includeTimestamps: false, + includeTaskItems: true, + }); + const boardDisplayName = snapshot.boardName || fallbackBoardName; + const lists = snapshot.lists; + const boardSettings = snapshot.boardSettings || {}; const boardSourceTheme = typeof getBoardTemporalSourceTheme === 'function' ? getBoardTemporalSourceTheme(boardSettings || {}) : null; diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index 21c3022..fbddccd 100644 --- a/app/board/renderBoard.js +++ b/app/board/renderBoard.js @@ -351,28 +351,28 @@ async function renderBoard() { try { const boardNameEl = document.getElementById('boardName'); - const [boardName, lists] = await Promise.all([ - window.board.getBoardName(boardRoot), - window.board.listLists(boardRoot), + const activeBoardView = typeof getActiveBoardView === 'function' + ? getActiveBoardView() + : 'kanban'; + const [snapshot] = await Promise.all([ + readBoardSnapshotForRender(boardRoot, { + includeBoardSettings: false, + includeTimestamps: activeBoardView === 'table', + includeTaskItems: false, + }), ensureBoardLabelsLoaded(), ]); + const boardName = snapshot.boardName || window.board.getBoardName(boardRoot); + const listsWithCards = snapshot.lists.map((listEntry) => ({ + listName: listEntry.listName, + listPath: listEntry.listPath, + cards: Array.isArray(listEntry.cards) ? listEntry.cards : [], + })); if (!isCurrentBoardRenderRequest(requestId)) { return; } - const activeBoardView = typeof getActiveBoardView === 'function' - ? getActiveBoardView() - : 'kanban'; - - const listsWithCards = await Promise.all( - lists.map(async (listName) => { - const listPath = boardRoot + listName; - const cards = await window.board.listCards(listPath); - return { listName, listPath, cards }; - }) - ); - if (activeBoardView === 'table' && typeof renderTableBoard === 'function') { const tableBuild = await renderTableBoard(boardRoot, listsWithCards); @@ -443,25 +443,24 @@ async function renderBoard() { group: 'lists', animation: (typeof prefersReducedMotion === 'function' && prefersReducedMotion()) ? 0 : 150, onEnd: async (evt) => { - const finalOrder = [...evt.to.querySelectorAll('.list')].map((list) => - list.getAttribute('data-path') - ); - - let directoryCounter = 0; - for (const directoryPath of finalOrder) { - const directoryNumber = (directoryCounter).toLocaleString('en-US', { - minimumIntegerDigits: 3, - useGrouping: false - }); - - const newDirectoryName = window.boardRoot + directoryNumber + await window.board.getListDirectoryName(directoryPath).slice(3); - - await window.board.moveCard(directoryPath, newDirectoryName); - - directoryCounter++; + const finalOrder = [...evt.to.querySelectorAll('.list')] + .map((list) => list.getAttribute('data-path')) + .filter(Boolean); + + try { + if (!window.board || typeof window.board.reorderLists !== 'function') { + throw new Error('List reorder is unavailable.'); + } + + await window.board.reorderLists(finalOrder); + } catch (error) { + console.error('Failed to reorder lists.', error); + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus('List order could not be saved.'); + } + } finally { + await renderBoard(); } - - await renderBoard(); } })); } diff --git a/app/board/tableView.js b/app/board/tableView.js index c72551e..53817f3 100644 --- a/app/board/tableView.js +++ b/app/board/tableView.js @@ -188,9 +188,17 @@ async function collectBoardTableCards(boardRoot, listsWithCards) { const rowsByList = await Promise.all( listEntries.map(async (listEntry) => { const rows = await Promise.all( - listEntry.cards.map(async (cardName) => { - const cardPath = `${listEntry.listPath}/${cardName}`; - const card = await window.board.readCard(cardPath); + listEntry.cards.map(async (cardItem) => { + const cardName = typeof getBoardSnapshotCardName === 'function' + ? getBoardSnapshotCardName(cardItem) + : String(cardItem || ''); + const cardPath = typeof getBoardSnapshotCardPath === 'function' + ? getBoardSnapshotCardPath(listEntry.listPath, cardItem) + : `${listEntry.listPath}/${cardName}`; + const snapshotCard = typeof getBoardSnapshotCardData === 'function' + ? getBoardSnapshotCardData(cardItem) + : null; + const card = snapshotCard || await window.board.readCard(cardPath); const frontmatter = card && card.frontmatter && typeof card.frontmatter === 'object' ? card.frontmatter : {}; @@ -211,11 +219,19 @@ async function collectBoardTableCards(boardRoot, listsWithCards) { ? frontmatter.labels.map((labelId) => String(labelId)) : [], body, - taskSummary: getTaskListSummary(body), - taskStartDates: typeof getTaskListStartDates === 'function' ? getTaskListStartDates(body) : [], - incompleteTaskStartDates: typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(body) : [], - taskDueDates: getTaskListDueDates(body), - incompleteTaskDueDates: getIncompleteTaskListDueDates(body), + taskSummary: card.taskSummary && typeof card.taskSummary === 'object' + ? card.taskSummary + : getTaskListSummary(body), + taskStartDates: Array.isArray(card.taskStartDates) + ? card.taskStartDates + : (typeof getTaskListStartDates === 'function' ? getTaskListStartDates(body) : []), + incompleteTaskStartDates: Array.isArray(card.incompleteTaskStartDates) + ? card.incompleteTaskStartDates + : (typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(body) : []), + taskDueDates: Array.isArray(card.taskDueDates) ? card.taskDueDates : getTaskListDueDates(body), + incompleteTaskDueDates: Array.isArray(card.incompleteTaskDueDates) + ? card.incompleteTaskDueDates + : getIncompleteTaskListDueDates(body), linkedObjectCount: typeof getFrontmatterLinkedObjectCount === 'function' ? getFrontmatterLinkedObjectCount(frontmatter) : 0, diff --git a/app/cards/createCardElement.js b/app/cards/createCardElement.js index 30fd7db..07a2463 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -572,8 +572,13 @@ async function toggleCardDateSelector(options = {}) { focusFirstCardDatePopoverControl(popover); } -async function createCardElement(cardPath) { - const card = await window.board.readCard(cardPath); +async function createCardElement(cardPath, options = {}) { + const suppliedCard = options && typeof options === 'object' + ? (typeof getBoardSnapshotCardData === 'function' + ? getBoardSnapshotCardData(options.card || options.cardRecord) + : (options.card || options.cardRecord || null)) + : null; + const card = suppliedCard || await window.board.readCard(cardPath); const listDirectoryName = String(cardPath || '').replace(/\\/g, '/').split('/').slice(-2, -1)[0] || ''; const isCompletedList = typeof isBoardListCompletedByWorkflow === 'function' ? isBoardListCompletedByWorkflow(listDirectoryName) @@ -584,11 +589,19 @@ async function createCardElement(cardPath) { let selectedLabelIds = Array.isArray(card.frontmatter.labels) ? card.frontmatter.labels.map((labelId) => String(labelId)) : []; - const taskSummary = getTaskListSummary(card.body); - const taskStartDates = typeof getTaskListStartDates === 'function' ? getTaskListStartDates(card.body) : []; - const incompleteTaskStartDates = typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(card.body) : taskStartDates; - const taskDueDates = getTaskListDueDates(card.body); - const incompleteTaskDueDates = getIncompleteTaskListDueDates(card.body); + const taskSummary = card.taskSummary && typeof card.taskSummary === 'object' + ? card.taskSummary + : getTaskListSummary(card.body); + const taskStartDates = Array.isArray(card.taskStartDates) + ? card.taskStartDates + : (typeof getTaskListStartDates === 'function' ? getTaskListStartDates(card.body) : []); + const incompleteTaskStartDates = Array.isArray(card.incompleteTaskStartDates) + ? card.incompleteTaskStartDates + : (typeof getIncompleteTaskListStartDates === 'function' ? getIncompleteTaskListStartDates(card.body) : taskStartDates); + const taskDueDates = Array.isArray(card.taskDueDates) ? card.taskDueDates : getTaskListDueDates(card.body); + const incompleteTaskDueDates = Array.isArray(card.incompleteTaskDueDates) + ? card.incompleteTaskDueDates + : getIncompleteTaskListDueDates(card.body); const linkedObjectCount = typeof getFrontmatterLinkedObjectCount === 'function' ? getFrontmatterLinkedObjectCount(card.frontmatter) : 0; diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 0ecd9b6..b51ad29 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -69,8 +69,14 @@ async function createListElement(name, listPath, cardNames, options = {}) { cardsEl.setAttribute('aria-label', `${listName.textContent || 'List'} cards`); listEl.appendChild(cardsEl); + const cardItems = Array.isArray(cardNames) ? cardNames : []; const cardElements = await Promise.all( - cardNames.map((cardName) => createCardElement(listPath + '/' + cardName)) + cardItems.map((cardItem) => { + const cardPath = typeof getBoardSnapshotCardPath === 'function' + ? getBoardSnapshotCardPath(listPath, cardItem) + : `${listPath}/${cardItem}`; + return createCardElement(cardPath, { cardRecord: cardItem }); + }) ); for (const cardEl of cardElements) { @@ -88,62 +94,25 @@ async function createListElement(name, listPath, cardNames, options = {}) { draggable: '.card', disabled: isBoardLabelFilterActive(), onEnd: async (evt) => { - const movedCardOriginalPath = evt && evt.item ? evt.item.getAttribute('data-path') : ''; - const sourceListPath = evt && evt.from ? evt.from.dataset.path : ''; const targetListPath = evt && evt.to ? evt.to.dataset.path : ''; - - const finalOrder = [...evt.to.querySelectorAll('.card')].map(card => - card.getAttribute('data-path') // array of CURRENT filenames in final order - ); - - const allCardsInList = await window.board.listCards(evt.to.dataset.path); - - let tempFileCounter = 0; - for (const fileName of allCardsInList) { - await window.board.moveCard(evt.to.dataset.path + '/' + fileName, evt.to.dataset.path + '/' + fileName.replace('.md','.tmp')); + const finalOrder = [...evt.to.querySelectorAll('.card')] + .map((card) => card.getAttribute('data-path')) + .filter(Boolean); + + try { + if (!window.board || typeof window.board.reorderCardsInList !== 'function') { + throw new Error('Card reorder is unavailable.'); + } + + await window.board.reorderCardsInList(targetListPath, finalOrder); + } catch (error) { + console.error('Failed to reorder cards.', error); + if (typeof announceSignboardStatus === 'function') { + announceSignboardStatus('Card order could not be saved.'); + } + } finally { + await renderBoard(); } - - let fileCounter = 0; - let movedCardNextPath = ''; - for (const filePath of finalOrder) { - - let fileNumber = (fileCounter).toLocaleString('en-US', { - minimumIntegerDigits: 3, - useGrouping: false - }); - - let adjustedFrom; - - if ( !filePath.includes( evt.to.dataset.path ) ) { - adjustedFrom = filePath; - } else { - adjustedFrom = filePath.replace('.md','.tmp'); - } - - let adjustedTo = evt.to.dataset.path + '/' + fileNumber + await window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); - - await window.board.moveCard(adjustedFrom, adjustedTo); - if (movedCardOriginalPath && filePath === movedCardOriginalPath) { - movedCardNextPath = adjustedTo; - } - - fileCounter++; - - } - - if ( - movedCardNextPath && - sourceListPath && - targetListPath && - sourceListPath !== targetListPath && - window.board && - typeof window.board.recordCardListMove === 'function' - ) { - await window.board.recordCardListMove(movedCardNextPath, sourceListPath, targetListPath); - } - - await renderBoard(); - } })); }; diff --git a/buildjs.sh b/buildjs.sh index cad4378..8930ebf 100755 --- a/buildjs.sh +++ b/buildjs.sh @@ -16,6 +16,7 @@ cat \ app/appSettings.js \ app/board/boardLabels.js \ app/board/boardSearch.js \ + app/board/boardSnapshot.js \ app/board/boardViews.js \ app/board/tableView.js \ app/board/plannerView.js \ diff --git a/docs/codex/FILE_STRUCTURE.md b/docs/codex/FILE_STRUCTURE.md index 25e8c20..f775b18 100644 --- a/docs/codex/FILE_STRUCTURE.md +++ b/docs/codex/FILE_STRUCTURE.md @@ -9,7 +9,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `AGENTS.md` - Cross-tool compatibility entrypoint that points agents to `CODEX.md`. - `DESIGN.md` - Design.md-compatible default theme tokens and visual rationale for Signboard's UI. - `MCP_README.md` - Dedicated setup guide for Signboard MCP server mode (`--mcp-server`). -- `preload.js` - Thin renderer bridge (`window.board`, `window.chooser`, `window.electronAPI`) that forwards allowed operations to main-process IPC and main-process-triggered renderer events, including board switcher/view/settings/Quick Add/signboard-card-link/signboard-board-link events, clipboard text copy, Smart Card Actions, Ollama model inspection, archive browse/read/restore, board duplication, Obsidian actions, dropped-file path extraction for linked objects, and top-of-list card move calls. +- `preload.js` - Thin renderer bridge (`window.board`, `window.chooser`, `window.electronAPI`) that forwards allowed operations to main-process IPC and main-process-triggered renderer events, including board switcher/view/settings/Quick Add/signboard-card-link/signboard-board-link events, clipboard text copy, Smart Card Actions, Ollama model inspection, archive browse/read/restore, board snapshot reads, board duplication, Obsidian actions, dropped-file path extraction for linked objects, and transactional card/list reorder plus top-of-list card move calls. - `index.html` - App shell, header board tab strip, left-edge Planner rail/overlay markup, fixed Sponsor pill, board-menu view/archive/switcher modal markup (including `#boardViewButton`, `#modalKeyboardShortcuts`, `#modalBoardSwitcher`, `#modalArchiveBrowser`, and `#modalObsidianVaultRequired`), and deferred script/style includes. - `readme.md` - Human-facing project README. - `docs/release-template.md` - Curated GitHub release-body template for public download links. @@ -36,6 +36,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/appSettings.js` - Renderer app-settings state, app-wide Settings/Notifications/Smart Actions controls, tooltip/notification/Quick Add global shortcut/AI assistance/External Published Calendar persistence scheduling, and one-time migration from legacy board settings; shared defaults/normalizers come from `shared/appSettingsSchema.js`. - `app/board/boardLabels.js` - Board-label state, completed-list workflow settings, shared shortcut-label helpers, header filter UI (`Today` / `Overdue` / next-range date filters + label filters, with date filters ignoring completed task date markers and completed workflow lists), keyboard-operable card label popovers with inline label creation and Labels settings shortcut, new-card label selection helpers, Settings modal app/current-board panel nav, General board rename/move/duplicate controls, Obsidian Base generation controls, and Trello/Obsidian import panel wiring + summary rendering. - `app/board/boardSearch.js` - Board search state, input handling for title/body filtering, and keyboard navigation from the search field through visible card results. +- `app/board/boardSnapshot.js` - Renderer adapter for batched `readBoardSnapshot` results, with fallback to legacy per-list/per-card reads for tests or older bridges. - `app/board/boardViews.js` - Shared Kanban/Planner temporal helpers, Kanban/Table board view state and menu controls, Calendar/This Week layout helpers, temporal card placement by card start/due and open task start/due markers, and source-list/source-board pills on temporal cards. - `app/board/tableView.js` - Board-scoped Table view rendering, dense row metadata including Start/Due, Created/Updated age columns, and linked-object counts, board filter/search reuse, Table list filter and sort controls, checkbox/shift-range bulk selection, bulk archive/move/label/date actions, and list-column card moves through the top-of-list move IPC path. - `app/board/plannerView.js` - Workspace-level Planner overlay with Calendar, This Week, Day, and Agenda views across currently open boards, all/current/custom board scope controls, Planner-local search/date/completed-card/board/active-board-label filters, keyboard navigation for Planner search/filter controls, left-rail open/close behavior, and Planner card opening that switches the active board when needed. @@ -47,7 +48,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `app/cards/processAddNewList.js` - New list creation flow. - `app/lists/listActionsPopover.js` - List action popover rendering for adding cards/lists, moving lists left/right, archiving cards/lists, keyboard option navigation, shortcut hints, and status announcements. - `app/lists/createListElement.js` - List DOM rendering with labelled section/list semantics, sanitized rename, card DnD handling, and cross-list move lifecycle logging. -- `app/board/renderBoard.js` - Whole-board render (with concurrent card-list reads), active Kanban/Table view dispatch, and Kanban list DnD handling. +- `app/board/renderBoard.js` - Whole-board render using batched board snapshots, active Kanban/Table view dispatch, and Kanban list DnD handling through main-process transactional reorder. - `app/board/openBoard.js` - Board open/init logic and starter content. - `app/modals/closeAllModals.js` - Modal close logic + editor cleanup + conditional rerender + board interaction lock/unlock. - `app/modals/toggleAddCardModal.js` - Add-card modal position/toggle. @@ -62,10 +63,12 @@ This map focuses on source and operational files. Large generated/vendor folders ## Shared/library code - `shared/appSettingsSchema.js` - Pure app-settings defaults and normalizers shared by the main process and renderer bundle; the single source for built-in Smart Card Action prompts. +- `lib/atomicFile.js` - Shared durable write helper that writes to a same-directory temp file, fsyncs, renames into place, and best-effort fsyncs the containing directory. +- `lib/boardSnapshot.js` - Main-process batched board reader used by renderer Kanban/Table/Planner views; returns list/card records, opt-in timestamps/task metadata/board settings, and per-card/list read errors. - `lib/cardFrontmatter.js` - Card parse/normalize/read/write/update with legacy support, including `start` and `due` date normalization. - `lib/cardLifecycle.js` - Shared card lifecycle metadata helper for `createdAt`, compact `activity` trails, archive frontmatter state, and moved/restored transitions. - `lib/cardTimestamps.js` - Shared timestamp resolver for desktop reads, CLI card records/JSON output, and MCP card responses, preferring frontmatter/activity creation data and filesystem modification data. -- `lib/cardOrdering.js` - Shared list-card ordering helper used by main-process/MCP restore and move flows to insert a card at the top while renumbering existing files. +- `lib/cardOrdering.js` - Shared transactional ordering helpers used by main-process/MCP restore and move flows to insert a card at the top, reorder cards in a list, and reorder list directories while staging temp names and rolling back on failures. - `lib/archive.js` - Archive/archive-list filesystem operations plus archive listing/detail/restore helpers and legacy archive fallback handling. - `lib/boardLabels.js` - Board-level label/theme/workflow/External Published Calendar inclusion settings read/write/defaults/filter helpers (`board-settings.md`) plus legacy app-setting extraction for migration. - `lib/boardDuplication.js` - Board folder duplication helper used by desktop Settings; copies board contents, assigns fresh copied-card IDs, refreshes copied Signboard metadata, rewrites internal `signboard://open-card` references, rewrites copied local linked-object paths, and resets copied managed Base state. @@ -89,6 +92,7 @@ This map focuses on source and operational files. Large generated/vendor folders - `scripts/test-frontmatter.js` - Node assertions for frontmatter behavior. - `scripts/test-board-labels.js` - Node assertions for board label settings defaults/migration/filter logic. +- `scripts/test-board-snapshot.js` - Node assertions for batched board snapshot list/card reads, task metadata, timestamps, board settings, and archive inclusion behavior. - `scripts/test-board-duplication.js` - Node assertions for board folder duplication, copied-card ID refresh, internal Signboard link rewrites, linked-object path rewrites, and copied managed Base reset behavior. - `scripts/test-app-settings.js` - Node assertions for app-wide settings persistence, including AI settings, and one-time board-settings migration. - `scripts/test-ai-task-suggestions.js` - Node assertions for Ollama model-list/chat request construction, Smart Card Action output parsing including label references, and AI checklist suggestion cleanup without live network calls. diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 157bf1c..3d3be99 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -41,10 +41,12 @@ File: `main.js` - Uses `preload.js` as a thin renderer bridge into main-process IPC. - Owns renderer right-click text editing context menus through the `webContents` `context-menu` event, covering editable fields such as the card title and OverType notes editor; context-menu popup creation is deferred one tick so AppKit can finish native menu tracking before window layout changes. - Owns trusted board-root persistence, board path validation, and external board filesystem watchers. +- Owns batched board snapshot reads through `lib/boardSnapshot.js`, returning list/card records plus optional timestamps, task metadata, board settings, and per-entry read errors for renderer Kanban/Table/Planner views. - Owns explicit board import operations for Trello, Obsidian, and Tasks.md; renderer code passes tokenized selections and the main process performs all external file reads and board writes. - Owns outbound Obsidian operations through `lib/obsidianIntegration.js`: containing-vault detection, Obsidian URI construction, default-app opening, managed generated Bases files, linked note creation, inbox note appends, and Signboard deep-link copying/resolution. - Owns archive browse/read/restore operations through `lib/archive.js`; renderer code never scans or restores archive contents directly. - Owns adjacent-card top-of-list moves through `moveCardToTop`, backed by `lib/cardOrdering.js`. +- Owns transactional card/list drag reorder through `reorderCardsInList` and `reorderLists`, backed by `lib/cardOrdering.js` staging/rollback helpers instead of renderer-side multi-rename loops. - Owns board duplication through `lib/boardDuplication.js`; renderer code supplies a tokenized destination folder selection and the main process copies the board, refreshes copied card IDs/metadata, resets copied managed Base state, and trusts the new board root. - In MCP mode, starts `lib/mcpServer.js`, passes desktop trusted board roots plus the last synced desktop open-board state into it, and communicates over stdio using MCP JSON-RPC framing. - MCP stdio transport supports both `Content-Length` framing and newline-delimited JSON-RPC for client compatibility. @@ -162,7 +164,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**`, - Renders Kanban columns by default. - Renders the board-scoped Table view for dense card scanning and bulk management. - Enables list drag-and-drop reorder in Kanban. - - Fetches each list's card names concurrently for faster initial render. + - Uses the batched `readBoardSnapshot` IPC path through `app/board/boardSnapshot.js` so normal rendering gets lists and parsed cards with one main-process operation; heavier fields such as timestamps, task item detail, and board settings are requested only by the views that need them. - Loads board label definitions and temporary filter state before rendering cards. - `app/board/tableView.js`: - Renders active-board cards in board/list order as a dense table. @@ -200,7 +202,7 @@ Files: `index.html`, `app/signboard.js` (generated), source modules in `app/**`, - Uses shared Sortable card drag options from `app/utilities/cardDragTilt.js`; the visible ghost placeholder is an empty drop slot rather than a readable duplicate card. - Sanitizes list names before filesystem rename. - Builds card DOM for a list concurrently to reduce list render time. - - Records `moved-list` lifecycle events only for real cross-list card moves, not same-list reindexing. + - Delegates card drag/drop filesystem ordering to main-process transactional reorder helpers, which record `moved-list` lifecycle events only for real cross-list card moves, not same-list reindexing. - `app/lists/listActionsPopover.js`: - Renders native button actions for adding cards/lists, moving lists left/right, and archiving cards/lists. - Keeps the popover labelled, focuses the first enabled action on open, supports arrow-key / `Home` / `End` / `Esc` option navigation, and announces completed list actions through the shared live status helper. @@ -348,6 +350,15 @@ File: `lib/cardBodyEdits.js` - Shared card-body helper for Markdown section replacement, insertion below headings, and timestamped note list items used by CLI and MCP card writes. +File: `lib/atomicFile.js` + +- Shared durable write helper for Signboard-managed card/settings/sidecar/state files and Obsidian-managed files. Writes go to a same-directory temp file, fsync, atomic rename into place, and best-effort directory fsync. + +File: `lib/boardSnapshot.js` + +- Batches board list/card reads for renderer view rendering and returns per-entry errors so one unreadable card does not need to fail the whole board. +- Includes normalized card frontmatter/body plus opt-in timestamps, task metadata, board settings, and Archive-list inclusion. + File: `lib/archive.js` - Owns archive/archive-list filesystem operations plus archive browsing and restore. @@ -505,6 +516,11 @@ CLI overdue behavior: - Script: `scripts/test-board-duplication.js` - Covers board folder copying, copied-card ID refresh, internal `signboard://open-card` rewrites, local linked-object path rewrites, and managed Base reset behavior. +### Board snapshot tests +- `npm run test:board-snapshot` +- Script: `scripts/test-board-snapshot.js` +- Covers batched board snapshot reads, task metadata, timestamps, board settings, and archive inclusion behavior. + ### AI task suggestion tests - `npm run test:ai-task-suggestions` - Script: `scripts/test-ai-task-suggestions.js` diff --git a/lib/appSettings.js b/lib/appSettings.js index d0fb5dd..d399a3c 100644 --- a/lib/appSettings.js +++ b/lib/appSettings.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const path = require('path'); const appSettingsSchema = require('../shared/appSettingsSchema'); +const { atomicWriteFile } = require('./atomicFile'); const { APP_SETTINGS_FILE_NAME, @@ -55,7 +56,7 @@ async function writeAppSettings(userDataPath, settings) { const settingsPath = getAppSettingsPath(userDataPath); const normalized = normalizeAppSettings(settings); await fs.mkdir(userDataPath, { recursive: true }); - await fs.writeFile(settingsPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + await atomicWriteFile(settingsPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); return { settingsPath, ...normalized, diff --git a/lib/archive.js b/lib/archive.js index 3c92256..51a8f8f 100644 --- a/lib/archive.js +++ b/lib/archive.js @@ -3,6 +3,7 @@ const path = require('path'); const boardLabels = require('./boardLabels'); const cardFrontmatter = require('./cardFrontmatter'); const { insertCardFileAtTop } = require('./cardOrdering'); +const { atomicWriteFile } = require('./atomicFile'); const { clearCardArchiveState, formatActivityTimestamp, @@ -331,7 +332,7 @@ async function writeArchivedListMetadata(listPath, metadata = {}) { activity: normalizeArchivedListActivityEntries(metadata.activity), }; - await fs.writeFile( + await atomicWriteFile( getArchivedListMetadataPath(listPath), `${JSON.stringify(payload, null, 2)}\n`, 'utf8', diff --git a/lib/boardLabels.js b/lib/boardLabels.js index 7c6b365..da46423 100644 --- a/lib/boardLabels.js +++ b/lib/boardLabels.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const path = require('path'); const yaml = require('js-yaml'); +const { atomicWriteFile } = require('./atomicFile'); const SETTINGS_FILE_NAME = 'board-settings.md'; const LEGACY_SETTINGS_FILE_NAME = 'labels.md'; @@ -550,7 +551,7 @@ async function writeBoardSettings(boardRoot, settings) { const normalized = normalizeBoardSettings(settings); const serialized = serializeBoardSettings(normalized); - await fs.writeFile(primaryPath, serialized, 'utf8'); + await atomicWriteFile(primaryPath, serialized, 'utf8'); return { settingsPath: primaryPath, diff --git a/lib/cardFrontmatter.js b/lib/cardFrontmatter.js index 9ea1770..346f5d7 100644 --- a/lib/cardFrontmatter.js +++ b/lib/cardFrontmatter.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const path = require('path'); const yaml = require('js-yaml'); +const { atomicWriteFile } = require('./atomicFile'); const LEGACY_DELIMITER = '**********'; const YAML_FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/; @@ -359,7 +360,7 @@ async function writeCard(filePath, card = {}) { const serialized = serializeCard(frontmatter, body, filePath); - await fs.writeFile(filePath, serialized, 'utf8'); + await atomicWriteFile(filePath, serialized, 'utf8'); } async function updateFrontmatter(filePath, partialFrontmatter = {}) { diff --git a/lib/cardOrdering.js b/lib/cardOrdering.js index 78edc99..31b9dc1 100644 --- a/lib/cardOrdering.js +++ b/lib/cardOrdering.js @@ -29,6 +29,17 @@ function applyCardPrefix(fileName, prefix) { return `${nextPrefix}-${normalized}`; } +function applyNumberedPrefix(entryName, prefix) { + const normalized = String(entryName || '').trim(); + const nextPrefix = toNumberedPrefix(prefix); + + if (/^\d+-/.test(normalized)) { + return normalized.replace(/^\d+/, nextPrefix); + } + + return `${nextPrefix}-${normalized.replace(/^-+/, '')}`; +} + async function listCardFiles(listPath) { const entries = await fs.readdir(listPath, { withFileTypes: true }); return entries @@ -59,6 +70,126 @@ async function restoreRenameIfPossible(fromPath, toPath) { } } +async function getDirectoryEntryNames(directoryPath, predicate) { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + return entries + .filter((entry) => (typeof predicate === 'function' ? predicate(entry) : true)) + .map((entry) => entry.name) + .sort((left, right) => cardSortCollator.compare(left, right)); +} + +async function getExistingOrderedSources(sourcePaths) { + const sources = []; + const seen = new Set(); + + for (const sourcePath of Array.isArray(sourcePaths) ? sourcePaths : []) { + const sourcePathText = String(sourcePath || '').trim(); + if (!sourcePathText) { + continue; + } + + const normalizedPath = path.resolve(sourcePathText); + if (seen.has(normalizedPath)) { + continue; + } + + if (!(await pathExists(normalizedPath))) { + continue; + } + + seen.add(normalizedPath); + sources.push(normalizedPath); + } + + return sources; +} + +function assertNoDuplicateFinalNames(finalNames) { + const seen = new Set(); + for (const finalName of finalNames) { + if (seen.has(finalName)) { + throw new Error(`ORDER_COLLISION:${finalName}`); + } + seen.add(finalName); + } +} + +async function reorderEntriesInDirectory({ + directoryPath, + orderedSourcePaths, + currentEntryNames, + applyPrefix, +}) { + const resolvedDirectoryPath = path.resolve(directoryPath); + const currentEntries = Array.isArray(currentEntryNames) ? currentEntryNames : []; + const currentPaths = currentEntries.map((entryName) => path.join(resolvedDirectoryPath, entryName)); + const currentPathSet = new Set(currentPaths); + const finalSources = await getExistingOrderedSources(orderedSourcePaths); + const finalSourceSet = new Set(finalSources); + + for (const currentPath of currentPaths) { + if (!finalSourceSet.has(currentPath)) { + finalSources.push(currentPath); + finalSourceSet.add(currentPath); + } + } + + const finalNames = finalSources.map((sourcePath, index) => applyPrefix(path.basename(sourcePath), index)); + assertNoDuplicateFinalNames(finalNames); + + const stagedEntries = []; + const stagedByPath = new Map(); + const finalizedEntries = []; + const tempToken = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + try { + for (let index = 0; index < currentEntries.length; index += 1) { + const originalEntryName = currentEntries[index]; + const originalPath = path.join(resolvedDirectoryPath, originalEntryName); + const tempEntryName = `__sbtmp-${tempToken}-${String(index).padStart(3, '0')}.tmp`; + const tempPath = path.join(resolvedDirectoryPath, tempEntryName); + await fs.rename(originalPath, tempPath); + + const stagedEntry = { + originalEntryName, + originalPath, + tempEntryName, + tempPath, + }; + stagedEntries.push(stagedEntry); + stagedByPath.set(originalPath, stagedEntry); + } + + for (let index = 0; index < finalSources.length; index += 1) { + const sourcePath = finalSources[index]; + const stagedEntry = stagedByPath.get(sourcePath); + const fromPath = stagedEntry ? stagedEntry.tempPath : sourcePath; + const finalEntryName = finalNames[index]; + const finalPath = path.join(resolvedDirectoryPath, finalEntryName); + await fs.rename(fromPath, finalPath); + finalizedEntries.push({ + originalSourcePath: sourcePath, + finalEntryName, + finalPath, + restorePath: stagedEntry ? stagedEntry.tempPath : sourcePath, + wasExistingEntry: currentPathSet.has(sourcePath), + }); + } + } catch (error) { + for (const finalizedEntry of finalizedEntries.slice().reverse()) { + await restoreRenameIfPossible(finalizedEntry.finalPath, finalizedEntry.restorePath); + } + + for (const stagedEntry of stagedEntries.slice().reverse()) { + await restoreRenameIfPossible(stagedEntry.tempPath, stagedEntry.originalPath); + } + + throw error; + } + + return finalizedEntries; +} + async function insertCardFileAtTop(listPath, sourcePath, sourceFileName) { const resolvedListPath = path.resolve(listPath); const resolvedSourcePath = path.resolve(sourcePath); @@ -115,6 +246,47 @@ async function insertCardFileAtTop(listPath, sourcePath, sourceFileName) { return insertedFileName; } +async function reorderCardFilesInList(listPath, orderedCardPaths) { + const resolvedListPath = path.resolve(listPath); + const currentCardFiles = await listCardFiles(resolvedListPath); + const finalizedEntries = await reorderEntriesInDirectory({ + directoryPath: resolvedListPath, + orderedSourcePaths: orderedCardPaths, + currentEntryNames: currentCardFiles, + applyPrefix: applyCardPrefix, + }); + + return finalizedEntries.map((entry) => ({ + sourcePath: entry.originalSourcePath, + cardFile: entry.finalEntryName, + cardPath: entry.finalPath, + wasExistingCard: entry.wasExistingEntry, + })); +} + +async function reorderListDirectories(boardRoot, orderedListPaths) { + const resolvedBoardRoot = path.resolve(boardRoot); + const currentListDirectories = await getDirectoryEntryNames( + resolvedBoardRoot, + (entry) => entry.isDirectory() && entry.name !== 'XXX-Archive', + ); + const finalizedEntries = await reorderEntriesInDirectory({ + directoryPath: resolvedBoardRoot, + orderedSourcePaths: orderedListPaths, + currentEntryNames: currentListDirectories, + applyPrefix: applyNumberedPrefix, + }); + + return finalizedEntries.map((entry) => ({ + sourcePath: entry.originalSourcePath, + listDirectoryName: entry.finalEntryName, + listPath: entry.finalPath, + wasExistingList: entry.wasExistingEntry, + })); +} + module.exports = { insertCardFileAtTop, + reorderCardFilesInList, + reorderListDirectories, }; diff --git a/lib/cliState.js b/lib/cliState.js index ff85b78..6231d61 100644 --- a/lib/cliState.js +++ b/lib/cliState.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const os = require('os'); const path = require('path'); +const { atomicWriteFile } = require('./atomicFile'); const CLI_STATE_FILE_NAME = 'cli-state.json'; @@ -51,7 +52,7 @@ async function readCliState(options = {}) { async function writeCliState(state, options = {}) { const statePath = getCliStatePath(options); await fs.mkdir(path.dirname(statePath), { recursive: true }); - await fs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf8'); + await atomicWriteFile(statePath, JSON.stringify(state, null, 2), 'utf8'); return statePath; } diff --git a/lib/obsidianIntegration.js b/lib/obsidianIntegration.js index f63a72d..7477625 100644 --- a/lib/obsidianIntegration.js +++ b/lib/obsidianIntegration.js @@ -2,6 +2,7 @@ const fs = require('fs').promises; const path = require('path'); const { createHash } = require('crypto'); const yaml = require('js-yaml'); +const { atomicWriteFile } = require('./atomicFile'); const CARD_ID_PATTERN = /-([A-Za-z0-9]{5})\.md$/; const LIST_NAME_PATTERN = /^(\d{3}-)(.*?)(-[^-]{5}|-stock)$/; @@ -272,7 +273,7 @@ async function writeObsidianBaseFile(boardRoot) { const vaultRoot = await findObsidianVaultRoot(resolvedBoardRoot); const basePath = path.join(resolvedBoardRoot, DEFAULT_BASE_FILE_NAME); const baseYaml = buildGeneratedBaseYaml(resolvedBoardRoot, vaultRoot); - await fs.writeFile(basePath, baseYaml, 'utf8'); + await atomicWriteFile(basePath, baseYaml, 'utf8'); return { ok: true, basePath, @@ -317,7 +318,7 @@ async function writeManagedObsidianBaseFile(boardRoot, options = {}) { } if (!hasExistingBase || force) { - await fs.writeFile(basePath, baseYaml, 'utf8'); + await atomicWriteFile(basePath, baseYaml, 'utf8'); return { ok: true, basePath, @@ -343,7 +344,7 @@ async function writeManagedObsidianBaseFile(boardRoot, options = {}) { } if (previousManagedHash && existingHash === previousManagedHash) { - await fs.writeFile(basePath, baseYaml, 'utf8'); + await atomicWriteFile(basePath, baseYaml, 'utf8'); return { ok: true, basePath, @@ -420,7 +421,7 @@ async function createLinkedObsidianNote({ } } - await fs.writeFile(notePath, buildLinkedNoteContent({ + await atomicWriteFile(notePath, buildLinkedNoteContent({ cardId, boardName, }), 'utf8'); @@ -485,7 +486,7 @@ async function createLinkedObsidianNoteAtPath({ } await fs.mkdir(path.dirname(resolvedNotePath), { recursive: true }); - await fs.writeFile(resolvedNotePath, buildLinkedNoteContent({ + await atomicWriteFile(resolvedNotePath, buildLinkedNoteContent({ cardId, boardName, }), 'utf8'); diff --git a/main.js b/main.js index a20d249..6ac0545 100644 --- a/main.js +++ b/main.js @@ -14,7 +14,8 @@ const path = require('path'); const { pathToFileURL } = require('url'); const cardFrontmatter = require('./lib/cardFrontmatter'); const { readCardWithTimestamps } = require('./lib/cardTimestamps'); -const { insertCardFileAtTop } = require('./lib/cardOrdering'); +const { insertCardFileAtTop, reorderCardFilesInList, reorderListDirectories } = require('./lib/cardOrdering'); +const { readBoardSnapshot } = require('./lib/boardSnapshot'); const { prepareNewCardFrontmatter } = require('./lib/cardLifecycle'); const { archiveCard, @@ -4066,6 +4067,14 @@ ipcMain.handle('board-call', async (event, payload = {}) => { return listBoardDirectories(boardRoot, { includeArchive: true }); } + case 'readBoardSnapshot': { + const boardRoot = requireReadableBoardRoot(event.sender, args[0]); + const options = args[1] && typeof args[1] === 'object' && !Array.isArray(args[1]) + ? args[1] + : {}; + return readBoardSnapshot(boardRoot, options); + } + case 'startBoardWatch': return startBoardWatchForSender(event.sender, args[0]); @@ -4375,6 +4384,67 @@ ipcMain.handle('board-call', async (event, payload = {}) => { return recordCardListMove(boardRoot, cardPath, fromListPath, toListPath); } + case 'reorderCardsInList': { + const boardRoot = requireActiveBoardRootForSender(event.sender); + const targetListPath = requireWritablePath(event.sender, args[0]); + const rawOrderedCardPaths = Array.isArray(args[1]) ? args[1] : []; + const orderedCardPaths = rawOrderedCardPaths + .map((cardPath) => requireWritablePath(event.sender, cardPath)) + .filter((cardPath) => cardPath.endsWith('.md')); + const archiveRoot = path.join(boardRoot, 'XXX-Archive'); + + if (targetListPath === boardRoot || targetListPath === archiveRoot || isPathInsideRoot(archiveRoot, targetListPath)) { + throw new Error('INVALID_TARGET_LIST'); + } + + const targetStats = await fsPromises.stat(targetListPath); + if (!targetStats.isDirectory()) { + throw new Error('INVALID_TARGET_LIST'); + } + + for (const cardPath of orderedCardPaths) { + const sourceListPath = path.dirname(cardPath); + if (sourceListPath === archiveRoot || isPathInsideRoot(archiveRoot, sourceListPath)) { + throw new Error('SOURCE_CARD_CANNOT_BE_ARCHIVED'); + } + } + + const reorderedCards = await reorderCardFilesInList(targetListPath, orderedCardPaths); + let movedAcrossLists = false; + + for (const cardEntry of reorderedCards) { + const sourceListPath = path.dirname(cardEntry.sourcePath); + if (sourceListPath !== targetListPath) { + movedAcrossLists = true; + await recordCardListMove(boardRoot, cardEntry.cardPath, sourceListPath, targetListPath); + await refreshCardSignboardMetadata(boardRoot, cardEntry.cardPath); + } + } + + if (movedAcrossLists) { + await autoSyncManagedObsidianBaseForBoard(boardRoot); + } + + return { + ok: true, + cards: reorderedCards, + }; + } + + case 'reorderLists': { + const boardRoot = requireActiveBoardRootForSender(event.sender); + const rawOrderedListPaths = Array.isArray(args[0]) ? args[0] : []; + const orderedListPaths = rawOrderedListPaths + .map((listPath) => requireWritablePath(event.sender, listPath)) + .filter((listPath) => path.dirname(listPath) === boardRoot && path.basename(listPath) !== 'XXX-Archive'); + + const reorderedLists = await reorderListDirectories(boardRoot, orderedListPaths); + return { + ok: true, + lists: reorderedLists, + }; + } + case 'moveCardToTop': { const boardRoot = requireActiveBoardRootForSender(event.sender); const sourcePath = requireWritablePath(event.sender, args[0]); diff --git a/package.json b/package.json index f0baf07..fecd7a5 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:frontmatter": "node scripts/test-frontmatter.js", "test:card-ordering": "node scripts/test-card-ordering.js", "test:board-labels": "node scripts/test-board-labels.js", + "test:board-snapshot": "node scripts/test-board-snapshot.js", "test:board-duplication": "node scripts/test-board-duplication.js", "test:app-settings": "node scripts/test-app-settings.js", "test:ai-task-suggestions": "node scripts/test-ai-task-suggestions.js", diff --git a/preload.js b/preload.js index c2b6faa..0e6e2a8 100644 --- a/preload.js +++ b/preload.js @@ -56,6 +56,7 @@ contextBridge.exposeInMainWorld('board', { getCardFileName: (filePath) => getNormalizedBaseName(filePath), getListDirectoryName: (filePath) => getNormalizedBaseName(filePath), listDirectories: async (root) => invokeBoard('listDirectories', root), + readBoardSnapshot: async (root, options) => invokeBoard('readBoardSnapshot', root, options), startBoardWatch: async (boardRoot) => invokeBoard('startBoardWatch', boardRoot), stopBoardWatch: async () => invokeBoard('stopBoardWatch'), getBoardWatchToken: async () => invokeBoard('getBoardWatchToken'), @@ -102,6 +103,9 @@ contextBridge.exposeInMainWorld('board', { invokeBoard('restoreArchivedList', archivedListPath, restoredDirectoryName), recordCardListMove: async (cardPath, fromListPath, toListPath) => invokeBoard('recordCardListMove', cardPath, fromListPath, toListPath), + reorderCardsInList: async (listPath, orderedCardPaths) => + invokeBoard('reorderCardsInList', listPath, orderedCardPaths), + reorderLists: async (orderedListPaths) => invokeBoard('reorderLists', orderedListPaths), moveCardToTop: async (cardPath, targetListPath) => invokeBoard('moveCardToTop', cardPath, targetListPath), moveCard: async (src, dst) => invokeBoard('moveCard', src, dst), moveList: async (src, dst) => invokeBoard('moveList', src, dst), diff --git a/scripts/test-board-views.js b/scripts/test-board-views.js index 428204c..152f7a1 100644 --- a/scripts/test-board-views.js +++ b/scripts/test-board-views.js @@ -279,6 +279,7 @@ function createContext() { loadSource(context, 'app/utilities/linkedObjects.js'); loadSource(context, 'app/board/boardLabels.js'); loadSource(context, 'app/board/boardSearch.js'); + loadSource(context, 'app/board/boardSnapshot.js'); loadSource(context, 'app/board/boardViews.js'); loadSource(context, 'app/board/tableView.js'); loadSource(context, 'app/board/plannerView.js'); diff --git a/scripts/test-card-ordering.js b/scripts/test-card-ordering.js index c0ac7ab..49dbfc6 100644 --- a/scripts/test-card-ordering.js +++ b/scripts/test-card-ordering.js @@ -2,7 +2,7 @@ const assert = require('assert'); const fs = require('fs').promises; const os = require('os'); const path = require('path'); -const { insertCardFileAtTop } = require('../lib/cardOrdering'); +const { insertCardFileAtTop, reorderCardFilesInList, reorderListDirectories } = require('../lib/cardOrdering'); async function pathExists(targetPath) { try { @@ -93,9 +93,109 @@ async function testInsertCardFileAtTopRollback() { } } +async function testReorderCardFilesInList() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-card-reorder-')); + const listPath = path.join(root, '000-To-do-stock'); + + try { + await fs.mkdir(listPath, { recursive: true }); + await fs.writeFile(path.join(listPath, '000-first-card-aaaaa.md'), 'first', 'utf8'); + await fs.writeFile(path.join(listPath, '001-second-card-bbbbb.md'), 'second', 'utf8'); + await fs.writeFile(path.join(listPath, '002-third-card-ccccc.md'), 'third', 'utf8'); + + const result = await reorderCardFilesInList(listPath, [ + path.join(listPath, '002-third-card-ccccc.md'), + path.join(listPath, '000-first-card-aaaaa.md'), + path.join(listPath, '001-second-card-bbbbb.md'), + ]); + + assert.deepStrictEqual(result.map((entry) => entry.cardFile), [ + '000-third-card-ccccc.md', + '001-first-card-aaaaa.md', + '002-second-card-bbbbb.md', + ]); + assert.deepStrictEqual(await listEntries(listPath), [ + '000-third-card-ccccc.md', + '001-first-card-aaaaa.md', + '002-second-card-bbbbb.md', + ]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +async function testReorderCardFilesAcrossLists() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-card-cross-reorder-')); + const sourceList = path.join(root, '000-Source-stock'); + const targetList = path.join(root, '001-Target-stock'); + + try { + await fs.mkdir(sourceList, { recursive: true }); + await fs.mkdir(targetList, { recursive: true }); + const sourcePath = path.join(sourceList, '004-moving-card-ddddd.md'); + await fs.writeFile(sourcePath, 'moving', 'utf8'); + await fs.writeFile(path.join(targetList, '000-existing-card-aaaaa.md'), 'existing', 'utf8'); + await fs.writeFile(path.join(targetList, '001-second-card-bbbbb.md'), 'second', 'utf8'); + + const result = await reorderCardFilesInList(targetList, [ + path.join(targetList, '000-existing-card-aaaaa.md'), + sourcePath, + path.join(targetList, '001-second-card-bbbbb.md'), + ]); + + assert.deepStrictEqual(result.map((entry) => entry.cardFile), [ + '000-existing-card-aaaaa.md', + '001-moving-card-ddddd.md', + '002-second-card-bbbbb.md', + ]); + assert.deepStrictEqual(await listEntries(targetList), [ + '000-existing-card-aaaaa.md', + '001-moving-card-ddddd.md', + '002-second-card-bbbbb.md', + ]); + assert.strictEqual(await pathExists(sourcePath), false); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +async function testReorderListDirectories() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-list-reorder-')); + + try { + const todo = path.join(root, '000-To-do-stock'); + const doing = path.join(root, '001-Doing-stock'); + const done = path.join(root, '002-Done-stock'); + const archive = path.join(root, 'XXX-Archive'); + await fs.mkdir(todo, { recursive: true }); + await fs.mkdir(doing, { recursive: true }); + await fs.mkdir(done, { recursive: true }); + await fs.mkdir(archive, { recursive: true }); + + const result = await reorderListDirectories(root, [done, todo, doing]); + + assert.deepStrictEqual(result.map((entry) => entry.listDirectoryName), [ + '000-Done-stock', + '001-To-do-stock', + '002-Doing-stock', + ]); + assert.deepStrictEqual(await listEntries(root), [ + '000-Done-stock', + '001-To-do-stock', + '002-Doing-stock', + 'XXX-Archive', + ]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + async function run() { await testInsertCardFileAtTop(); await testInsertCardFileAtTopRollback(); + await testReorderCardFilesInList(); + await testReorderCardFilesAcrossLists(); + await testReorderListDirectories(); console.log('Card ordering tests passed.'); } diff --git a/tests/playwright/signboard-smoke.spec.js b/tests/playwright/signboard-smoke.spec.js index 561190b..52ce7b2 100644 --- a/tests/playwright/signboard-smoke.spec.js +++ b/tests/playwright/signboard-smoke.spec.js @@ -458,6 +458,29 @@ async function waitForBoardWatch(page) { }, { timeout: 5000 }).toBeGreaterThan(0); } +async function removePathWithRetries(targetPath, options = {}) { + const retryableCodes = new Set(['EBUSY', 'ENOTEMPTY', 'EPERM']); + let lastError = null; + + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + lastError = error; + if (!error || !retryableCodes.has(error.code) || attempt === 5) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, 75 * (attempt + 1))); + } + } + + if (lastError) { + throw lastError; + } +} + const test = base.extend({ boardRoot: async ({}, use) => { const fixture = await createFixtureBoard(); @@ -465,7 +488,7 @@ const test = base.extend({ try { await use(fixture.boardRoot); } finally { - await fs.rm(fixture.root, { recursive: true, force: true }); + await removePathWithRetries(fixture.root, { recursive: true, force: true }); } }, @@ -475,7 +498,7 @@ const test = base.extend({ try { await use(userDataDir); } finally { - await fs.rm(userDataDir, { recursive: true, force: true }); + await removePathWithRetries(userDataDir, { recursive: true, force: true }); } }, @@ -787,7 +810,14 @@ test('exposes Obsidian actions and generates a board Base', async ({ page, board await page.locator('#cardEditorLinkedObjectsLink').click(); await expect(page.locator('#cardEditorLinkedObjectsPopover')).toBeVisible(); await page.locator('#cardEditorLinkedObjectsPopover').getByRole('button', { name: 'Create Linked Obsidian Note' }).click(); - await fs.access(linkedNotePath); + await expect.poll(async () => { + try { + await fs.access(linkedNotePath); + return true; + } catch { + return false; + } + }).toBe(true); const linkedNoteRaw = await fs.readFile(linkedNotePath, 'utf8'); expect(linkedNoteRaw).toContain('signboard_card_id: stock'); expect(linkedNoteRaw).not.toContain('# Plan release notes'); @@ -860,7 +890,14 @@ test('exposes Obsidian actions and generates a board Base', async ({ page, board await expect(page.locator('#signboardStatusRegion')) .toHaveText('Linked note not found.'); await page.locator('#cardEditorRelatedNotes').getByRole('button', { name: 'Recreate Renamed Project Brief' }).click(); - await fs.access(renamedLinkedNotePath); + await expect.poll(async () => { + try { + await fs.access(renamedLinkedNotePath); + return true; + } catch { + return false; + } + }).toBe(true); await expect(page.locator('#cardEditorRelatedNotes .card-editor-related-note.is-missing')).toHaveCount(0); await expect(page.locator('#cardEditorRelatedNotes').getByRole('button', { name: 'Open Renamed Project Brief' })).toBeVisible(); await page.locator('#cardEditorRelatedNotes').getByRole('button', { name: 'Remove Renamed Project Brief' }).click(); From 332b68e443e28e6a3069a64849a536f3dc06310e Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Tue, 23 Jun 2026 10:37:44 -0400 Subject: [PATCH 08/10] Removed video outline. Updated docs. Improved speed in board switching. --- app/board/boardSnapshot.js | 132 ++++++ ...ignboard-feature-showcase-video-outline.md | 437 ------------------ lib/atomicFile.js | 59 +++ lib/boardSnapshot.js | 185 ++++++++ readme.md | 4 +- scripts/test-board-snapshot.js | 85 ++++ 6 files changed, 464 insertions(+), 438 deletions(-) create mode 100644 app/board/boardSnapshot.js delete mode 100644 docs/signboard-feature-showcase-video-outline.md create mode 100644 lib/atomicFile.js create mode 100644 lib/boardSnapshot.js create mode 100644 scripts/test-board-snapshot.js diff --git a/app/board/boardSnapshot.js b/app/board/boardSnapshot.js new file mode 100644 index 0000000..f4e811e --- /dev/null +++ b/app/board/boardSnapshot.js @@ -0,0 +1,132 @@ +function joinBoardSnapshotPath(parentPath, entryName) { + const parent = String(parentPath || '').replace(/[\\/]+$/, ''); + const entry = String(entryName || '').replace(/^[\\/]+/, ''); + return parent && entry ? `${parent}/${entry}` : `${parent}${entry}`; +} + +function isBoardSnapshotCardRecord(cardItem) { + return Boolean(cardItem && typeof cardItem === 'object' && !Array.isArray(cardItem)); +} + +function getBoardSnapshotCardName(cardItem) { + if (typeof cardItem === 'string') { + return cardItem; + } + + if (isBoardSnapshotCardRecord(cardItem)) { + const cardName = String(cardItem.cardName || '').trim(); + if (cardName) { + return cardName; + } + + const cardPath = String(cardItem.cardPath || '').replace(/\\/g, '/'); + return cardPath.split('/').filter(Boolean).pop() || ''; + } + + return ''; +} + +function getBoardSnapshotCardPath(listPath, cardItem) { + if (isBoardSnapshotCardRecord(cardItem)) { + const cardPath = String(cardItem.cardPath || '').trim(); + if (cardPath) { + return cardPath; + } + } + + return joinBoardSnapshotPath(listPath, getBoardSnapshotCardName(cardItem)); +} + +function getBoardSnapshotCardData(cardItem) { + if (!isBoardSnapshotCardRecord(cardItem)) { + return null; + } + + if (cardItem.frontmatter && typeof cardItem.frontmatter === 'object') { + return cardItem; + } + + if (cardItem.card && typeof cardItem.card === 'object') { + return cardItem.card; + } + + return null; +} + +function normalizeBoardSnapshotList(boardRoot, listEntry) { + if (typeof listEntry === 'string') { + const listName = listEntry; + return { + listName, + listPath: joinBoardSnapshotPath(boardRoot, listName), + cards: [], + }; + } + + const source = listEntry && typeof listEntry === 'object' ? listEntry : {}; + const listName = String(source.listName || source.name || '').trim(); + return { + listName, + listPath: String(source.listPath || source.path || joinBoardSnapshotPath(boardRoot, listName)).trim(), + cards: Array.isArray(source.cards) ? source.cards : [], + }; +} + +function normalizeBoardSnapshot(boardRoot, snapshot = {}) { + const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const lists = Array.isArray(source.lists) + ? source.lists.map((listEntry) => normalizeBoardSnapshotList(boardRoot, listEntry)).filter((listEntry) => listEntry.listName) + : []; + + return { + ok: source.ok !== false, + boardRoot: String(source.boardRoot || boardRoot || '').trim(), + boardName: String(source.boardName || '').trim(), + boardSettings: source.boardSettings && typeof source.boardSettings === 'object' + ? source.boardSettings + : null, + lists, + errors: Array.isArray(source.errors) ? source.errors : [], + }; +} + +async function readBoardSnapshotForRender(boardRoot, options = {}) { + if (window.board && typeof window.board.readBoardSnapshot === 'function') { + try { + const snapshot = await window.board.readBoardSnapshot(boardRoot, options); + if (snapshot && Array.isArray(snapshot.lists)) { + return normalizeBoardSnapshot(boardRoot, snapshot); + } + } catch (error) { + console.warn('Unable to read batched board snapshot; falling back to individual reads.', error); + } + } + + const listNames = await window.board.listLists(boardRoot); + const lists = await Promise.all( + listNames.map(async (listName) => { + const listPath = joinBoardSnapshotPath(boardRoot, listName); + const cards = await window.board.listCards(listPath); + return { + listName, + listPath, + cards: Array.isArray(cards) ? cards : [], + }; + }), + ); + const [boardName, boardSettings] = await Promise.all([ + Promise.resolve().then(() => window.board.getBoardName(boardRoot)).catch(() => ''), + options.includeBoardSettings !== false && window.board && typeof window.board.readBoardSettings === 'function' + ? window.board.readBoardSettings(boardRoot).catch(() => null) + : Promise.resolve(null), + ]); + + return normalizeBoardSnapshot(boardRoot, { + ok: true, + boardRoot, + boardName, + boardSettings, + lists, + errors: [], + }); +} diff --git a/docs/signboard-feature-showcase-video-outline.md b/docs/signboard-feature-showcase-video-outline.md deleted file mode 100644 index bb020ec..0000000 --- a/docs/signboard-feature-showcase-video-outline.md +++ /dev/null @@ -1,437 +0,0 @@ -# Signboard Feature Showcase Video Outline - -Use this as a chaptered recording script for a long-form Signboard walkthrough. The goal is not to click every control in isolation, but to show how the app works as one coherent local-first workflow: boards, files, cards, dates, archive, imports, CLI, and MCP. - -## Recording Setup - -- Close every board except `Signboard Demo`. -- Keep the app window large enough to show three lists, the board header, and the Planner rail. -- Have Finder ready at the demo board folder: - `/Users/cdevroe/Documents/Documents - Colin's MacBook Pro/signboards/Signboard Demo` -- Have Terminal ready for the CLI section. -- Have `MCP_README.md` or the in-app `Help > Copy MCP Config` action ready for the MCP section. -- Make a copy of the demo board before recording if you plan to create, archive, restore, move, or edit real cards on camera. - -Current demo board state: - -- Lists: `To-do` with 12 cards, `Doing` with 8 cards, `Done` with 7 cards. -- Labels: `Content`, `Social media`, `Launch`, `Overdue`, `New`. -- Color scheme: `rosewood`. -- Archive: contains archived cards and archived lists, so the archive browser is ready to show. -- Date data: most active card due dates are March/April 2026. For a recording made near May 23, 2026, refresh a few card and checklist dates so Planner and date filters show useful current examples. - -Suggested date prep for the week of May 23, 2026: - -- Set one active card due on `2026-05-23` so Today has content. -- Set one active card due on `2026-05-24` or `2026-05-25` so upcoming calendar/week views are not empty. -- Set one active card due before `2026-05-23` so Overdue has content. -- Add one incomplete checklist item with `(due: 2026-05-23)`. -- Add one checked checklist item with `(due: 2026-05-23)` so you can explain that completed task due markers keep history but no longer make a card actionable. -- Keep at least one dated card in `Done` to show completed-list hiding. - -## Chapter Outline - -### 1. Intro and Positioning - -Action: - -- Start on the demo board in Kanban view. -- Give the one-sentence promise: Signboard is a local-first desktop board app where boards are folders, lists are directories, and cards are Markdown files. -- Tell viewers this is a long video with chapters, so they can jump to the features they care about. - -Talk track: - -- "I am going to show Signboard as a real working app, not a slide deck." -- "The big idea is that the UI is friendly, but the data stays simple and portable." -- "Later I will show the same board from Finder, Terminal, and an agent through MCP." - -### 2. Local-First Storage Model - -Action: - -- Switch to Finder. -- Open the `Signboard Demo` board folder. -- Show list folders such as `000-To-do-stock`, `001-Doing-stock`, `002-Done-stock`. -- Open one card Markdown file. -- Point out YAML frontmatter, `title`, `due`, `labels`, Markdown body, and task due markers. -- Show `board-settings.md`. -- Show `XXX-Archive`. - -Talk track: - -- "A board is a folder." -- "A list is a folder inside the board." -- "A card is a Markdown file." -- "Board settings live with the board." -- "This is why backup, sync, editor access, CLI access, and agent access all work naturally." - -### 3. Opening Boards, Tabs, and Switching - -Action: - -- Return to Signboard. -- Show the active board tab. -- Open the quick board switcher with `Cmd + K`. -- Mention that multiple open boards use tabs and overflow into the switcher. -- If you want to make all-open-board Planner behavior visible, briefly open a second scratch board for this chapter and close it afterward. - -Talk track: - -- "Signboard can keep multiple boards open, but for this demo I am using one clean demo board." -- "The switcher searches currently open boards and is meant for fast keyboard-driven navigation." - -### 4. Kanban Basics: Lists and Cards - -Action: - -- Show the three lists: To-do, Doing, Done. -- Create a temporary list or explain list creation from the list action menu. -- Rename a list title inline if using a copied demo board. -- Move a list left/right from the list action menu or drag it if safe. -- Create a new card with the list's add-card button. -- Drag a card between lists. - -Talk track: - -- "Kanban is the default board view." -- "List order and card order are stored through the numbered folder/file names." -- "Drag-and-drop updates the underlying files, not a private database." -- "The empty drop slot shows exactly where the card will land." - -### 5. Card Editor Deep Dive - -Action: - -- Open a rich card, ideally one with labels, due date, body text, and checklist items. -- Edit the title. -- Add or change a due date. -- Add/remove labels. -- Add Markdown notes and checklist items. -- Right-click inside the title or body to show the native text editing menu. -- Use the list dropdown or next-list button to move the card. -- Duplicate a card. -- If using a backup copy, archive a disposable card. - -Talk track: - -- "The editor is a Markdown editor over a plain file." -- "Top-level card due dates and checklist item due dates are both understood by Signboard." -- "Checklist progress shows on cards." -- "Duplicating is useful for lightweight templates." -- "Archiving removes a card from the active board without deleting it." - -### 6. Quick Add - -Action: - -- Press `Cmd + N`. -- Show board and list selectors. -- Create a card in a selected list. -- Demonstrate `Shift + Enter` to create, open, and focus the notes field. - -Talk track: - -- "Quick Add is the fastest way to capture work without navigating first." -- "When multiple boards are open, you can choose the target board and list before creating the card." -- "There is also an optional app-level global Quick Add shortcut while Signboard is running." - -### 7. Search, Labels, and Date Filters - -Action: - -- Use `Cmd + F` to focus board search. -- Search for a visible term like `launch`, `content`, or `email`. -- Press `Enter` or `Arrow Down` to move from search into results. -- Open the filter popover. -- Filter by a label. -- Filter by Today or Overdue. -- Clear filters. - -Talk track: - -- "Search matches card titles and card bodies." -- "Filters combine with search, so you can narrow the board by text, labels, and due state." -- "Today and Overdue include open checklist item due dates, not just card due dates." -- "Completed checklist due markers and completed-list cards are treated as history by default." - -### 8. Table View - -Action: - -- Open `Board menu > View > Table`. -- Show dense scanning of cards across the active board. -- Use search/filter in Table view. -- Move a card using the row list dropdown if safe. -- Open a card from the table. -- Return to Kanban with `Cmd + 1`. - -Talk track: - -- "Table is still the current board, just shown in a denser format." -- "It reuses the same search, labels, date filters, task badges, and completed-list rules as Kanban." -- "This is useful when a board has many cards and you want to scan or triage quickly." - -### 9. Planner: Calendar, This Week, Day, Agenda - -Action: - -- Open Planner from the left rail or `Cmd + Shift + P`. -- Show Calendar. -- Switch to This Week. -- Switch to Day. -- Switch to Agenda. -- Show All Boards vs Current Board scope. With one board open, explain that all/current are equivalent for the demo. -- Use Planner search. -- Open the Planner filter menu. -- Toggle completed visibility if you have a dated card in `Done`. -- Click a Planner card to open the normal editor. - -Talk track: - -- "Planner is workspace-level. It looks across currently open boards." -- "It places both card due dates and open checklist item due dates onto dates." -- "Planner cards show their source board and list." -- "Completed-list cards are hidden by default so finished work does not look actionable." -- "You can still reveal completed dated cards when reviewing history." - -### 10. Completed Workflow Rules - -Action: - -- Open Settings with `Cmd + ,`. -- Go to Workflow. -- Show completed-list auto-detection for names like Done, Completed, Closed, Shipped. -- Show manual override controls. -- Show board-level External Published Calendar inclusion. - -Talk track: - -- "Completed work keeps its due dates, but date-driven views do not treat it as actionable by default." -- "Each board can decide which lists count as completed." -- "This is also used by due reminders and the External Published Calendar feed." - -### 11. Archive Browser and Restore - -Action: - -- Open Archive with `Cmd + Shift + A` or from the Board menu. -- Search archived cards. -- Select an archived card and show details. -- Restore a card into a destination list if safe. -- Show archived lists. -- Restore an archived list only if using a board copy. - -Talk track: - -- "Archive is not a trash can. It is active history." -- "Archived cards and lists live under `XXX-Archive`." -- "You can inspect details before restoring, and restore cards into the list you choose." - -### 12. Settings Tour - -Action: - -- Show App Settings: - - Settings: tooltips and optional global Quick Add shortcut - - Notifications: daily due-date reminders and External Published Calendar - - Smart Actions: AI assistance and Smart Card Action prompts -- Show Board General: - - rename board - - move board folder -- Show Labels: - - add, rename, color, remove labels -- Show Appearance: - - board color scheme - - light/dark mode - - apply to open boards -- Show Import: - - Trello - - Obsidian - - Tasks.md - -Talk track: - -- "App settings apply across Signboard." -- "Board settings live with the board in `board-settings.md`." -- "Labels and colors are board-specific, so each project can have its own vocabulary." -- "Imports are additive. They copy data into Signboard and do not modify the source files." - -### 13. External Published Calendar - -Action: - -- In App Settings > Notifications, show External Published Calendar controls. -- Explain the `127.0.0.1` subscription URL. -- Show the board Workflow inclusion toggle again if useful. - -Talk track: - -- "This is opt-in and served locally while Signboard is running." -- "The feed includes card due dates and unchecked task item due dates." -- "It excludes checked task due markers, completed-list cards, and boards you opt out." -- "Due items are all-day events because Signboard due dates are date-only." - -### 14. Keyboard Shortcuts and Accessibility - -Action: - -- Open the shortcut helper with `Cmd + /`. -- Show a few high-value shortcuts: - - `Cmd + N` Quick Add - - `Cmd + K` board switcher - - `Cmd + 1` Kanban - - `Cmd + Option + 1` Table - - `Cmd + Shift + P` Planner - - `Cmd + Shift + A` Archive - - `Cmd + ,` Settings -- Mention keyboard navigation in board search, Planner search, Archive search, tabs, filters, and settings. - -Talk track: - -- "You do not need to memorize all of these. The helper is always available." -- "The app is designed so common workflows can be done from the keyboard." -- "There are also screen-reader status announcements, keyboard-only focus styling, reduced-motion support, and forced-colors support." - -### 15. CLI for Scripts and Agents - -Action: - -- Switch to Terminal. -- Use the installed `signboard` command if available, or use the repo-local command while recording from the source checkout. -- Run read commands first. - -Example commands: - -```bash -DEMO_BOARD="/Users/cdevroe/Documents/Documents - Colin's MacBook Pro/signboards/Signboard Demo" - -signboard lists --board "$DEMO_BOARD" --json -signboard cards --board "$DEMO_BOARD" --search launch --json -signboard cards --board "$DEMO_BOARD" --due this-week --sort due --json -signboard cards read --board "$DEMO_BOARD" --card "Script and storyboard homepage demo video" -signboard archive cards --board "$DEMO_BOARD" --json -signboard settings --board "$DEMO_BOARD" --json -``` - -Optional write examples if using a copied board: - -```bash -signboard cards create --board "$DEMO_BOARD" --list "To-do" --title "Follow up from the demo" --dry-run --json -signboard cards create --board "$DEMO_BOARD" --list "To-do" --title "Follow up from the demo" --label Content -signboard cards notes add --board "$DEMO_BOARD" --card "Follow up from the demo" --text "Created from the CLI during the recording." --timestamp -signboard cards edit --board "$DEMO_BOARD" --card "Follow up from the demo" --move-to Doing -``` - -Talk track: - -- "The CLI is useful for scripts, shell workflows, and agents that prefer command execution." -- "Use `--json` for machine-readable output." -- "Use `--board` so scripts do not depend on global state." -- "Use `--dry-run` before writes when you want to preview a change." -- "When the desktop app is open, external board edits can auto-refresh in the UI." - -### 16. MCP for Agent Workflows - -Action: - -- In the app, show `Help > Copy MCP Config`. -- Open or reference `MCP_README.md`. -- Explain the core config fields: - - executable path - - `--mcp-server` - - `SIGNBOARD_MCP_READ_ONLY` - - `SIGNBOARD_MCP_ALLOWED_ROOTS` -- Describe a realistic agent flow. - -Talk track: - -- "MCP is the structured way for AI agents to work with Signboard." -- "It starts read-only by default." -- "Allowed roots control which board folders an agent can see or edit." -- "The app's trusted board roots are included, so boards you have opened in the desktop app can be resolved by MCP." -- "Agents can list boards, resolve a board by name, read lists and cards, create or update cards, duplicate cards, archive cards, move cards, read settings, update board settings, and run imports." -- "Card tool responses include task summary and task due-date metadata, which is useful for planning agents." -- "Dry-run writes let an agent show the intended card payload before touching files." - -Example agent prompts to show or narrate: - -```text -Using Signboard MCP, find overdue cards on the Signboard Demo board and group them by list. -``` - -```text -Using Signboard MCP, create a dry-run card in To-do titled "Review the demo recording" with a due date next week. -``` - -```text -Using Signboard MCP, add a timestamped note to the card "Script and storyboard homepage demo video" summarizing this review. -``` - -### 17. Direct Markdown Editing and Sync - -Action: - -- Open a card file in a text editor. -- Make a small body edit if using a copied board. -- Return to Signboard and show the external change reflected. -- If a card editor is open and clean, show that it refreshes after external edits. - -Talk track: - -- "Because cards are files, you can inspect and edit them outside the app." -- "Signboard watches trusted board roots and refreshes when files change." -- "This is what makes the CLI and MCP integrations feel native instead of bolted on." - -### 18. Sponsorship, Updates, and Wrap - -Action: - -- Show the Sponsor pill or Board menu Sponsor action briefly. -- Mention automatic/manual updates if useful. -- End on the board or Planner. - -Talk track: - -- "Signboard is free for personal use, and sponsorship supports work use and future development." -- "The app has a normal desktop update flow." -- "The main takeaway is that Signboard gives you a visual board without trapping your work in a private database." - -## Suggested YouTube Chapters - -Adjust timestamps after recording. - -```text -00:00 Intro -02:00 What Signboard is -05:00 Boards as folders and cards as Markdown -12:00 Opening boards, tabs, and quick switching -17:00 Kanban lists and card movement -27:00 Card editor, Markdown, labels, due dates, and checklists -40:00 Quick Add -47:00 Search, labels, and date filters -58:00 Table view -1:08:00 Planner: Calendar, This Week, Day, and Agenda -1:25:00 Completed workflow rules -1:33:00 Archive and restore -1:45:00 Settings -1:58:00 External Published Calendar -2:08:00 Keyboard shortcuts and accessibility -2:18:00 Signboard CLI -2:35:00 MCP and AI agent workflows -2:50:00 Direct Markdown edits and external sync -3:00:00 Sponsor, updates, and wrap -``` - -## Shorter Fallback Cut - -If the full recording runs too long, keep these core sections: - -1. Intro and local-first storage model. -2. Kanban basics. -3. Card editor with Markdown, labels, due dates, and checklists. -4. Search/filter. -5. Planner. -6. Archive. -7. Settings/imports. -8. CLI. -9. MCP. diff --git a/lib/atomicFile.js b/lib/atomicFile.js new file mode 100644 index 0000000..ca285f9 --- /dev/null +++ b/lib/atomicFile.js @@ -0,0 +1,59 @@ +const fs = require('fs').promises; +const path = require('path'); +const { randomUUID } = require('crypto'); + +async function syncDirectory(directoryPath) { + let directoryHandle = null; + + try { + directoryHandle = await fs.open(directoryPath, 'r'); + await directoryHandle.sync(); + } catch { + // Directory fsync is best-effort and is not supported on every platform. + } finally { + if (directoryHandle) { + await directoryHandle.close().catch(() => {}); + } + } +} + +async function atomicWriteFile(filePath, data, options = {}) { + const resolvedPath = path.resolve(filePath); + const directoryPath = path.dirname(resolvedPath); + const baseName = path.basename(resolvedPath); + const encoding = typeof options === 'string' + ? options + : (options && options.encoding) || undefined; + const tempPath = path.join( + directoryPath, + `.${baseName}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`, + ); + let fileHandle = null; + let shouldCleanupTemp = true; + + await fs.mkdir(directoryPath, { recursive: true }); + + try { + fileHandle = await fs.open(tempPath, 'wx'); + await fileHandle.writeFile(data, encoding ? { encoding } : undefined); + await fileHandle.sync(); + await fileHandle.close(); + fileHandle = null; + + await fs.rename(tempPath, resolvedPath); + shouldCleanupTemp = false; + await syncDirectory(directoryPath); + } finally { + if (fileHandle) { + await fileHandle.close().catch(() => {}); + } + + if (shouldCleanupTemp) { + await fs.unlink(tempPath).catch(() => {}); + } + } +} + +module.exports = { + atomicWriteFile, +}; diff --git a/lib/boardSnapshot.js b/lib/boardSnapshot.js new file mode 100644 index 0000000..b324124 --- /dev/null +++ b/lib/boardSnapshot.js @@ -0,0 +1,185 @@ +const fs = require('fs').promises; +const path = require('path'); +const boardLabels = require('./boardLabels'); +const cardFrontmatter = require('./cardFrontmatter'); +const { readCardWithTimestamps } = require('./cardTimestamps'); +const { + parseTaskListItems, +} = require('./taskList'); + +const ARCHIVE_DIRECTORY_NAME = 'XXX-Archive'; + +const snapshotNameCollator = new Intl.Collator(undefined, { + usage: 'sort', + sensitivity: 'base', + numeric: true, + ignorePunctuation: true, + localeMatcher: 'lookup', +}); + +function normalizeSnapshotError(error, targetPath) { + return { + path: targetPath, + code: error && typeof error.code === 'string' ? error.code : '', + message: error && typeof error.message === 'string' ? error.message : String(error || 'Unknown error'), + }; +} + +function compareNames(left, right) { + return snapshotNameCollator.compare(left, right); +} + +function getTaskSummaryFromItems(items) { + const taskItems = Array.isArray(items) ? items : []; + let completed = 0; + + for (const item of taskItems) { + if (item && item.isCompleted) { + completed += 1; + } + } + + return { + total: taskItems.length, + completed, + remaining: Math.max(0, taskItems.length - completed), + }; +} + +function collectTaskDateValues(items, fieldName, options = {}) { + const taskItems = Array.isArray(items) ? items : []; + const includeCompleted = options.includeCompleted === true; + const values = new Set(); + + for (const item of taskItems) { + if (!item || (!includeCompleted && item.isCompleted)) { + continue; + } + + const value = String(item[fieldName] || '').trim(); + if (value) { + values.add(value); + } + } + + return [...values].sort(); +} + +async function listBoardDirectories(boardRoot, options = {}) { + const includeArchive = options.includeArchive === true; + const entries = await fs.readdir(boardRoot, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((entryName) => includeArchive || entryName !== ARCHIVE_DIRECTORY_NAME) + .sort(compareNames); +} + +async function listMarkdownCardFileNames(listPath) { + const entries = await fs.readdir(listPath, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => entry.name) + .sort(compareNames); +} + +async function readSnapshotCard(cardPath, cardName, errors, options = {}) { + const includeTimestamps = options.includeTimestamps !== false; + const includeTaskItems = options.includeTaskItems !== false; + + try { + const card = includeTimestamps + ? await readCardWithTimestamps(cardPath) + : await cardFrontmatter.readCard(cardPath); + const body = String(card && typeof card.body === 'string' ? card.body : ''); + const taskItems = parseTaskListItems(body); + const snapshotCard = { + cardName, + cardPath, + frontmatter: card && card.frontmatter && typeof card.frontmatter === 'object' + ? card.frontmatter + : {}, + body, + taskSummary: getTaskSummaryFromItems(taskItems), + taskStartDates: collectTaskDateValues(taskItems, 'start', { includeCompleted: true }), + incompleteTaskStartDates: collectTaskDateValues(taskItems, 'start'), + taskDueDates: collectTaskDateValues(taskItems, 'due', { includeCompleted: true }), + incompleteTaskDueDates: collectTaskDateValues(taskItems, 'due'), + }; + + if (includeTimestamps) { + snapshotCard.timestamps = card && card.timestamps && typeof card.timestamps === 'object' + ? card.timestamps + : {}; + } + + if (includeTaskItems) { + snapshotCard.taskItems = taskItems; + } + + return snapshotCard; + } catch (error) { + errors.push(normalizeSnapshotError(error, cardPath)); + return null; + } +} + +async function readSnapshotList(boardRoot, listName, errors, options = {}) { + const listPath = path.join(boardRoot, listName); + let cardNames = []; + + try { + cardNames = await listMarkdownCardFileNames(listPath); + } catch (error) { + errors.push(normalizeSnapshotError(error, listPath)); + } + + const cards = (await Promise.all( + cardNames.map((cardName) => readSnapshotCard(path.join(listPath, cardName), cardName, errors, options)), + )).filter(Boolean); + + return { + listName, + listPath, + cards, + }; +} + +async function readBoardSnapshot(boardRoot, options = {}) { + const normalizedBoardRoot = path.resolve(String(boardRoot || '')); + const includeArchive = options.includeArchive === true; + const includeBoardSettings = options.includeBoardSettings !== false; + const snapshotOptions = { + includeTimestamps: options.includeTimestamps !== false, + includeTaskItems: options.includeTaskItems !== false, + }; + const errors = []; + const [listNames, boardSettings] = await Promise.all([ + listBoardDirectories(normalizedBoardRoot, { includeArchive }), + includeBoardSettings + ? boardLabels.readBoardSettings(normalizedBoardRoot, { + ensureFile: options.ensureBoardSettings !== false, + }).catch((error) => { + errors.push(normalizeSnapshotError(error, path.join(normalizedBoardRoot, 'board-settings.md'))); + return null; + }) + : Promise.resolve(null), + ]); + + const lists = await Promise.all( + listNames.map((listName) => readSnapshotList(normalizedBoardRoot, listName, errors, snapshotOptions)), + ); + + return { + ok: errors.length === 0, + boardRoot: normalizedBoardRoot, + boardName: path.basename(normalizedBoardRoot), + boardSettings, + lists, + errors, + }; +} + +module.exports = { + readBoardSnapshot, +}; diff --git a/readme.md b/readme.md index ecf9a97..7bb0c5c 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ Signboard is a local-first desktop kanban app that stores your lists as directories and cards as Markdown files on disk. -Signboard is free for personal use. If you are using Signboard for your work it would be appreciated if you purchase a commercial license to sponsor future development. See the app's "Sponsor" button. +Signboard is free for personal use. If you are using Signboard for your work, it would be appreciated if you make the commercial-use sponsorship payment to support future development. See the app's "Sponsor" button. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![GitHub issues](https://img.shields.io/github/issues/cdevroe/signboard)](../../issues) @@ -226,6 +226,8 @@ npm start ```bash npm run test:frontmatter npm run test:board-labels +npm run test:board-snapshot +npm run test:board-duplication npm run test:app-settings npm run test:ai-task-suggestions npm run test:board-card-metadata diff --git a/scripts/test-board-snapshot.js b/scripts/test-board-snapshot.js new file mode 100644 index 0000000..47c9399 --- /dev/null +++ b/scripts/test-board-snapshot.js @@ -0,0 +1,85 @@ +const assert = require('assert'); +const fs = require('fs').promises; +const os = require('os'); +const path = require('path'); +const cardFrontmatter = require('../lib/cardFrontmatter'); +const { readBoardSnapshot } = require('../lib/boardSnapshot'); + +async function testReadBoardSnapshot() { + const boardRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'signboard-board-snapshot-')); + const todoList = path.join(boardRoot, '000-To-do-stock'); + const doneList = path.join(boardRoot, '001-Done-stock'); + const archiveList = path.join(boardRoot, 'XXX-Archive'); + + try { + await fs.mkdir(todoList, { recursive: true }); + await fs.mkdir(doneList, { recursive: true }); + await fs.mkdir(archiveList, { recursive: true }); + await cardFrontmatter.writeCard(path.join(todoList, '000-alpha-card-abcde.md'), { + frontmatter: { + title: 'Alpha', + due: '2026-03-10', + labels: ['label-1'], + }, + body: '- [ ] (start: 2026-03-09) Prep\n- [x] (due: 2026-03-08) Done', + }); + await cardFrontmatter.writeCard(path.join(doneList, '000-finished-card-fghij.md'), { + frontmatter: { + title: 'Finished', + }, + body: 'Complete', + }); + await cardFrontmatter.writeCard(path.join(archiveList, '000-archived-card-klmno.md'), { + frontmatter: { + title: 'Archived', + }, + body: 'Archived', + }); + + const snapshot = await readBoardSnapshot(boardRoot); + + assert.strictEqual(snapshot.ok, true); + assert.strictEqual(snapshot.boardName, path.basename(boardRoot)); + assert.deepStrictEqual(snapshot.lists.map((list) => list.listName), [ + '000-To-do-stock', + '001-Done-stock', + ]); + assert(snapshot.boardSettings, 'expected board settings in snapshot'); + assert.strictEqual(snapshot.lists[0].cards.length, 1); + assert.strictEqual(snapshot.lists[0].cards[0].frontmatter.title, 'Alpha'); + assert.deepStrictEqual(snapshot.lists[0].cards[0].taskStartDates, ['2026-03-09']); + assert.deepStrictEqual(snapshot.lists[0].cards[0].taskDueDates, ['2026-03-08']); + assert.deepStrictEqual(snapshot.lists[0].cards[0].incompleteTaskDueDates, []); + assert(snapshot.lists[0].cards[0].timestamps.updatedAt, 'expected card timestamps'); + + const snapshotWithArchive = await readBoardSnapshot(boardRoot, { includeArchive: true }); + assert.deepStrictEqual(snapshotWithArchive.lists.map((list) => list.listName), [ + '000-To-do-stock', + '001-Done-stock', + 'XXX-Archive', + ]); + + const leanSnapshot = await readBoardSnapshot(boardRoot, { + includeBoardSettings: false, + includeTimestamps: false, + includeTaskItems: false, + }); + const leanCard = leanSnapshot.lists[0].cards[0]; + assert.strictEqual(leanSnapshot.boardSettings, null); + assert.strictEqual(Object.prototype.hasOwnProperty.call(leanCard, 'timestamps'), false); + assert.strictEqual(Object.prototype.hasOwnProperty.call(leanCard, 'taskItems'), false); + assert.deepStrictEqual(leanCard.taskStartDates, ['2026-03-09']); + } finally { + await fs.rm(boardRoot, { recursive: true, force: true }); + } +} + +async function run() { + await testReadBoardSnapshot(); + console.log('Board snapshot tests passed.'); +} + +run().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); From 412c95449db61244324b25a5f977d456d6f8f35a Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Tue, 23 Jun 2026 10:37:51 -0400 Subject: [PATCH 09/10] 1.6.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index afefbdb..5e26b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "signboard", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "signboard", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "electron-updater": "^6.8.3", diff --git a/package.json b/package.json index fecd7a5..738194c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "signboard", "productName": "Signboard", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "author": "Colin Devroe <colin@cdevroe.com> (https://cdevroe.com)", "main": "main.js", From 656cb39947d1d335b0b2f8637162d15f9248315f Mon Sep 17 00:00:00 2001 From: Colin Devroe <colin@cdevroe.com> Date: Wed, 24 Jun 2026 06:54:28 -0400 Subject: [PATCH 10/10] Harden native menu settle delay --- app/utilities/accessibility.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/utilities/accessibility.js b/app/utilities/accessibility.js index a96dbf0..3eab494 100644 --- a/app/utilities/accessibility.js +++ b/app/utilities/accessibility.js @@ -1,5 +1,6 @@ const SB_STATUS_REGION_ID = 'signboardStatusRegion'; -const SB_NATIVE_MENU_SETTLE_DELAY_MS = 0; +// Give AppKit time to exit native menu/select tracking before layout changes. +const SB_NATIVE_MENU_SETTLE_DELAY_MS = 100; const SB_MODAL_FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])',