diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 1d1ff1831..419a05724 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' import { harperWasmAsset } from '../../tooling/vite/harper-wasm-asset' +import { EXCALIDRAW_ASSET_PATH } from './src/renderer/excalidraw-asset-path' const INTERNAL_WORKSPACE_PACKAGES = [ '@zennotes/app-core', @@ -88,6 +89,29 @@ function onigurumaDataUrl(): Plugin { } } +// Excalidraw lists a CDN fallback (esm.sh) after EXCALIDRAW_ASSET_PATH in every +// FontFace it builds, and Chromium checks each listed source against the CSP +// when the FontFace is constructed, not when it loads. font-src does not allow +// esm.sh, so opening a drawing logged a CSP error for each of its 230 faces +// although every font loaded from zen-excalidraw: (#324), and the noise read as +// a font regression. Pointing the fallback at the same local base leaves every +// source CSP-allowed and keeps the fonts offline even if the global were unset. +// An Excalidraw upgrade that reshapes the fallback fails the build here rather +// than quietly bringing the noise back. +function excalidrawLocalFontFallback(base: string): Plugin { + const cdnFallback = /`https:\/\/esm\.sh\/\$\{.*?\}\/dist\/prod\/`/ + return { + name: 'zennotes-excalidraw-local-font-fallback', + transform(code, id) { + if (!code.includes('ASSETS_FALLBACK_URL')) return null + if (!cdnFallback.test(code)) { + this.error(`Excalidraw's esm.sh font fallback changed shape in ${id}`) + } + return { code: code.replace(cdnFallback, JSON.stringify(base)), map: null } + } + } +} + function rendererManualChunk(id: string): string | undefined { const normalizedId = id.split('\\').join('/') if (normalizedId.endsWith('/packages/app-core/src/lib/wikilinks.ts')) { @@ -307,6 +331,11 @@ export default defineConfig({ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src') } }, - plugins: [onigurumaDataUrl(), harperWasmAsset(), react()] + plugins: [ + onigurumaDataUrl(), + harperWasmAsset(), + excalidrawLocalFontFallback(EXCALIDRAW_ASSET_PATH), + react() + ] } }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9b3ff9e88..78c86af2b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.80.1", + "version": "2.81.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/cli/backend.ts b/apps/desktop/src/cli/backend.ts index 28ab3a72f..9aed17942 100644 --- a/apps/desktop/src/cli/backend.ts +++ b/apps/desktop/src/cli/backend.ts @@ -54,7 +54,7 @@ import { splitTaskId, toggleFileTaskInBody, toggleTask, - toggleTaskInBody, + toggleTaskBody, unarchiveNote, writeNote, writeNoteComments, @@ -475,20 +475,25 @@ class RemoteBackend implements VaultBackend { // explicit ask, and ids for excluded tasks only circulate via // `zn task list --include-excluded`. let nextBody: string | null + // A recurring task checked off moves down under its next occurrence + // (#1036); the reply names the finished task, wherever it went. + let resultId = taskId if (indexStr === 'task') { const current = ( await this.client.scanTasksForPath(rel, { includeExcluded: true }) ).find((t) => t.id === taskId) nextBody = current ? toggleFileTaskInBody(note.body, current.checked) : null } else { - nextBody = toggleTaskInBody(note.body, parseTaskIndex(taskId, indexStr)) + const toggled = toggleTaskBody(note.body, parseTaskIndex(taskId, indexStr)) + nextBody = toggled?.body ?? null + if (toggled) resultId = `${rel}#${toggled.index}` } if (nextBody == null) return null await this.client.writeNote(rel, nextBody) return ( (await this.client.scanTasksForPath(rel, { includeExcluded: true })).find( - (t) => t.id === taskId + (t) => t.id === resultId ) ?? null ) } diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 81f956698..032aca3f4 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -44,6 +44,7 @@ import { stopAppConfigWatcher } from './app-config' import { CONFIG_VERSION, PORTABLE_PREF_KEYS, type AppConfigPortable } from '@shared/app-config' +import { parse as parseToml } from 'smol-toml' const tempDirs: string[] = [] async function tmp(prefix: string): Promise { @@ -136,6 +137,7 @@ describe('TOML serialization', () => { kanbanLanes: 'field:sprint', hideEmptyTasks: false, kanbanWipLimits: { 'status:in-progress': 3, 'field:status:review': 2 }, + kanbanColumnColors: { 'status:in-progress': 'blue', 'field:status:done': 'green' }, tasksListOrder: 'priority', timelineRange: 'month', gallerySort: 'title', @@ -185,6 +187,14 @@ describe('TOML serialization', () => { expect(round.hideEmptyTasks).toBe(false) expect(text).toMatch(/\[kanban_wip_limits\]\n"status:in-progress" = 3\n"field:status:review" = 2/) expect(round.kanbanWipLimits).toEqual({ 'status:in-progress': 3, 'field:status:review': 2 }) + // #1037: column colours travel as palette names, next to the titles. + expect(text).toMatch( + /\[kanban_column_colors\]\n"status:in-progress" = "blue"\n"field:status:done" = "green"/ + ) + expect(round.kanbanColumnColors).toEqual({ + 'status:in-progress': 'blue', + 'field:status:done': 'green' + }) expect(text).toContain('tasks_list_order = "priority"') expect(round.tasksListOrder).toBe('priority') // The Timeline panel's reach travels as [view].timeline_range (#1007). @@ -565,3 +575,257 @@ describe('command keys in [keymaps.commands] (#971)', () => { expect(onDisk.split('[keymaps.commands]')[0]).not.toMatch(/^"note\.trash"/m) }) }) + +// zn, the terminal app, reads config.toml too and keeps settings of its own +// there. The app rebuilt the file from the options it knows, so zn's +// [terminal] table and keys like [vim] motions vanished at every launch and +// every settings change. +describe('settings the app does not write, for zn', () => { + const sharedFile = [ + 'config_version = 1', + 'zn_only = "kept" # a root key only zn reads', + '', + '[vim]', + 'enabled = false', + '# zn: vim | helix', + 'motions = "helix" # Helix-style selections in zn tui', + '', + '[editor]', + 'font_size = 19', + '', + '[keymaps]', + '"global.searchNotes" = "Mod+K"', + '', + '# zn tui only', + '[terminal]', + '# preview_style is a Glamour style name, or "zen"', + 'preview_style = "dracula"', + 'mouse = false', + 'banner = """', + '[not.a.table]', + 'just text"""', + 'images = [', + ' "kitty", # first choice', + ' "off",', + ']', + ' [terminal.view_mode_rules]', + ' "page-status=done" = "preview"', + '', + '[[terminal.profiles]]', + 'name = "work"', + '' + ].join('\n') + + const terminalBlock = [ + '# zn tui only', + '[terminal]', + '# preview_style is a Glamour style name, or "zen"', + 'preview_style = "dracula"', + 'mouse = false', + 'banner = """', + '[not.a.table]', + 'just text"""', + 'images = [', + ' "kitty", # first choice', + ' "off",', + ']' + ].join('\n') + + const sharedTerminal = { + preview_style: 'dracula', + mouse: false, + banner: '[not.a.table]\njust text', + images: ['kitty', 'off'], + view_mode_rules: { 'page-status=done': 'preview' }, + profiles: [{ name: 'work' }] + } + + it('keeps them through a launch, as written, comments included', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-') + await writeFile(getConfigFilePath(), sharedFile, 'utf8') + await initAppConfig(() => {}) + + const snap = getPortableConfigSnapshot() + expect(snap.vimMode).toBe(false) + expect(snap.editorFontSize).toBe(19) + expect(snap.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+K' }) + + const text = await readFile(getConfigFilePath(), 'utf8') + // The file is the app's canonical one again, every option documented... + expect(text).toContain('theme_mode = "dark" # light | dark | auto') + // ...with zn's tables after its own, as they were written. + expect(text).toContain(`${terminalBlock}\n\n [terminal.view_mode_rules]\n "page-status=done" = "preview"\n\n[[terminal.profiles]]\nname = "work"\n`) + expect(text.indexOf('# zn tui only')).toBeGreaterThan(text.indexOf('[saved_filters]')) + // A key zn keeps in a table the app writes stays in that table, with its comments. + expect(text).toMatch( + /\n\[vim\]\n(?:[a-z_]+ = .*\n)+# zn: vim \| helix\nmotions = "helix" # Helix-style selections in zn tui\n/ + ) + expect(text).toContain('config_version = 1\nzn_only = "kept" # a root key only zn reads\n') + + const parsed = parseToml(text) as Record + expect(parsed.terminal).toEqual(sharedTerminal) + expect(parsed.vim.motions).toBe('helix') + expect(parsed.zn_only).toBe('kept') + + // The next launch finds nothing to change: no rewrite on every start. + await stopAppConfigWatcher() + await initAppConfig(() => {}) + expect(await readFile(getConfigFilePath(), 'utf8')).toBe(text) + }) + + it('keeps them when a setting changes in the app', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-set-') + await writeFile(getConfigFilePath(), sharedFile, 'utf8') + await initAppConfig(() => {}) + + await setPortableConfig({ editorFontSize: 22, vimMode: true }) + + const text = await readFile(getConfigFilePath(), 'utf8') + expect(text).toContain('font_size = 22') + expect(text).toContain(terminalBlock) + const parsed = parseToml(text) as Record + expect(parsed.vim).toMatchObject({ enabled: true, motions: 'helix' }) + expect(parsed.terminal).toEqual(sharedTerminal) + }) + + it('keeps what zn saved after the app last read the file', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-race-') + await writeFile(getConfigFilePath(), serializeConfig({ editorFontSize: 18 }), 'utf8') + await initAppConfig(() => {}) + + // zn's writer (BurntSushi/toml) sorts keys, indents, and drops comments. + // A settings change lands before the watcher has read this. + await writeFile( + getConfigFilePath(), + [ + 'config_version = 1', + '', + '[editor]', + ' font_size = 18', + '', + '[terminal]', + ' math = "latex"', + ' view_mode_field = "view-mode"', + ' [terminal.view_mode_rules]', + ' "page-status=done" = "preview"', + '', + '[vim]', + ' enabled = true', + '' + ].join('\n'), + 'utf8' + ) + await setPortableConfig({ themeMode: 'light' }) + + const text = await readFile(getConfigFilePath(), 'utf8') + expect(text).toContain('theme_mode = "light"') + expect(text).toContain( + '[terminal]\n math = "latex"\n view_mode_field = "view-mode"\n\n [terminal.view_mode_rules]\n "page-status=done" = "preview"\n' + ) + }) + + it('still loads when a table it does not use is broken, and leaves that table for zn', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-broken-') + const broken = [ + 'config_version = 1', + '', + '[editor]', + 'font_size = 19', + '', + '[terminal]', + 'mouse = yes', + 'math = "latex"', + '' + ].join('\n') + expect(() => parseToml(broken)).toThrow() + await writeFile(getConfigFilePath(), broken, 'utf8') + + await initAppConfig(() => {}) + expect(getPortableConfigSnapshot().editorFontSize).toBe(19) + expect(await readFile(getConfigFilePath(), 'utf8')).toContain('[terminal]\nmouse = yes\nmath = "latex"\n') + + await setPortableConfig({ editorFontSize: 21 }) + const text = await readFile(getConfigFilePath(), 'utf8') + expect(text).toContain('font_size = 21') + expect(text).toContain('[terminal]\nmouse = yes\nmath = "latex"\n') + // Fixing the typo by hand makes the file plain TOML again. + expect(() => parseToml(text.replace('mouse = yes', 'mouse = true'))).not.toThrow() + }) + + it('keeps zn tables through the next save when the app options themselves are broken', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-own-broken-') + await writeFile( + getConfigFilePath(), + 'config_version = 1\n\n[editor]\nfont_size = 1 9\n\n[terminal]\nmouse = false\n', + 'utf8' + ) + // As before: a typo in the app's own options leaves them unread. + await initAppConfig(() => {}) + expect(getPortableConfigSnapshot()).toEqual({}) + + await setPortableConfig({ editorFontSize: 20 }) + const parsed = parseToml(await readFile(getConfigFilePath(), 'utf8')) as Record + expect(parsed.editor.font_size).toBe(20) + expect(parsed.terminal).toEqual({ mouse: false }) + }) + + it('carries a dotted key into a table it writes as data, inside that table', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-dotted-') + await writeFile( + getConfigFilePath(), + 'config_version = 1\nvim.motions = "helix"\nterminal.mouse = false\n\n[editor]\nfont_size = 18\n', + 'utf8' + ) + await initAppConfig(() => {}) + + const text = await readFile(getConfigFilePath(), 'utf8') + expect(text.match(/^\[vim\]$/gm)).toHaveLength(1) + const parsed = parseToml(text) as Record + expect(parsed.vim.motions).toBe('helix') + expect(parsed.terminal).toEqual({ mouse: false }) + expect(parsed.editor.font_size).toBe(18) + }) + + // The scan reads only the layout, so it must not take a bracket or a hash + // inside a value for a table or a comment. + it.each([ + [ + 'literal strings', + `[terminal]\na = 'C:\\path #not a comment [x]'\nb = "say \\"hi\\" # still the string"\nc = '''\n[not.a.table]\n# not a comment\n'''` + ], + ['a multi-line array', `[terminal]\nlist = [\n "a]", # ]\n 'b[',\n """c\n]""",\n]\nafter = 1`], + [ + 'headers with comments and quoted keys', + `[ terminal ] # zn\nmouse = true\n\n[ terminal . "rules.x" ] # sub\n"k=v" = 1` + ], + ['dates and special numbers', `[terminal]\nd = 1979-05-27T07:32:00Z\nt = 07:32:00\nf = inf\nn = nan\nh = 0xff`] + ])('carries %s as written', async (_name, block) => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-shape-') + await writeFile(getConfigFilePath(), `config_version = 1\n\n${block}\n`, 'utf8') + await initAppConfig(() => {}) + await setPortableConfig({ editorLineHeight: 1.7 }) + const text = await readFile(getConfigFilePath(), 'utf8') + expect(text.endsWith(`\n\n${block}\n`)).toBe(true) + expect(() => parseToml(text)).not.toThrow() + }) + + it('lets an entry set in the app replace a carried one with the same key', async () => { + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-collide-') + await writeFile( + getConfigFilePath(), + 'config_version = 1\n\n[folder_labels]\ninbox = true\narchive = "Old"\n', + 'utf8' + ) + await initAppConfig(() => {}) + // Not a string, so not a label the app writes: carried as it is. + expect(await readFile(getConfigFilePath(), 'utf8')).toMatch(/\[folder_labels\]\narchive = "Old"\ninbox = true\n/) + + await setPortableConfig({ systemFolderLabels: { inbox: 'Notes', archive: 'Old' } }) + const text = await readFile(getConfigFilePath(), 'utf8') + expect(text).not.toContain('inbox = true') + expect((parseToml(text) as Record).folder_labels).toEqual({ + inbox: 'Notes', + archive: 'Old' + }) + }) +}) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 5fa2f5069..efa1776d0 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -12,7 +12,7 @@ import { app } from 'electron' import path from 'node:path' import { promises as fs } from 'node:fs' import chokidar, { type FSWatcher } from 'chokidar' -import { parse as parseToml } from 'smol-toml' +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml' import { CONFIG_VERSION, PORTABLE_DEFAULTS, @@ -451,6 +451,16 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'hide_empty_tasks', comment: 'leave blank checkboxes out of the Tasks views, the Calendar and Timeline panels and the home view' }, + taskReminders: { + section: 'view', + tomlKey: 'task_reminders', + comment: 'true | false: notify for tasks with @remind (#1036)' + }, + taskReminderTime: { + section: 'view', + tomlKey: 'task_reminder_time', + comment: 'HH:MM: when @remind:1d and other reminders without a time fire' + }, tasksListOrder: { section: 'view', tomlKey: 'tasks_list_order', @@ -549,6 +559,14 @@ const MAP_TABLE_FIELDS: Partial> = { comment: ['Kanban WIP limits (1 to 999), keyed by ":".'], example: '"status:in-progress" = 3' }, + kanbanColumnColors: { + table: 'kanban_column_colors', + comment: [ + 'Kanban column colors, keyed by ":".', + 'gray, red, yellow, green, teal, blue or purple, drawn in the theme\'s own shades.' + ], + example: '"status:in-progress" = "blue"' + }, enabledOverrides: { table: 'overrides', comment: ['Enabled CSS overrides. List the filenames you want active.'], @@ -596,7 +614,8 @@ const FILE_HEADER = `# ZenNotes configuration # Every available option is listed below with its current value; the inline # comment shows the allowed values. Edit a value to customize it. ZenNotes # keeps this list complete, so a removed option reappears with its default. -# Note: comments you add yourself may be dropped when the app rewrites the file. +# Tables and keys the desktop app does not use, like zn's [terminal], stay as +# they are. Other comments you add may be dropped when the app rewrites the file. ` @@ -660,10 +679,16 @@ function tomlKey(key: string): string { /** * Build the TOML document text from a portable config object. Always emits * every option (filling unset ones from defaults) with an inline comment, plus - * the map tables with format examples, so the file documents itself. + * the map tables with format examples, so the file documents itself. What the + * file held for other programs (`carried`) goes back in: keys at the end of + * their table, other tables at the end of the file. */ -export function serializeConfig(portable: AppConfigPortable): string { +export function serializeConfig( + portable: AppConfigPortable, + carried: CarriedToml = NOTHING_CARRIED +): string { const lines: string[] = [`config_version = ${CONFIG_VERSION}`] + lines.push(...carriedEntries(carried, [], new Set(['config_version']))) const scalarKeys = Object.keys(SCALAR_FIELDS) as PortablePrefKey[] const listKeys = Object.keys(LIST_FIELDS) as PortablePrefKey[] @@ -693,10 +718,11 @@ export function serializeConfig(portable: AppConfigPortable): string { const arr = Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [] lines.push(`${map.tomlKey} = ${tomlArray(arr)} # ${map.comment}`) } + lines.push(...carriedEntries(carried, [section], SECTION_KEYS.get(tableId([section])) ?? new Set())) } - lines.push(...keymapSectionLines(portable.keymapOverrides)) - lines.push(...commandKeymapSectionLines(portable.commandKeymaps)) + lines.push(...keymapSectionLines(portable.keymapOverrides, carried)) + lines.push(...commandKeymapSectionLines(portable.commandKeymaps, carried)) for (const key of Object.keys(MAP_TABLE_FIELDS) as PortablePrefKey[]) { if (key === 'keymapOverrides') continue // emitted with its full reference above @@ -707,6 +733,7 @@ export function serializeConfig(portable: AppConfigPortable): string { lines.push(`# Example: ${def.example}`) lines.push(`[${def.table}]`) const value = portable[key] + const written = new Set() if (value && typeof value === 'object' && !Array.isArray(value)) { for (const [entryKey, entryValue] of Object.entries(value as Record)) { // Strings for most tables; [kanban_wip_limits] holds plain numbers. @@ -715,18 +742,22 @@ export function serializeConfig(portable: AppConfigPortable): string { (typeof entryValue === 'number' && Number.isFinite(entryValue)) ) { lines.push(`${tomlKey(entryKey)} = ${tomlValue(entryValue)}`) + written.add(entryKey) } } } + lines.push(...carriedEntries(carried, [def.table], written)) } + for (const table of carried.tables) lines.push('', table) + return FILE_HEADER + lines.join('\n') + '\n' } /** Build the `[keymaps]` block: active overrides followed by every action's * default binding as a commented, grouped reference so users can discover and * uncomment what they want to remap. */ -function keymapSectionLines(rawOverrides: unknown): string[] { +function keymapSectionLines(rawOverrides: unknown, carried: CarriedToml): string[] { const overrides: Record = {} if (rawOverrides && typeof rawOverrides === 'object' && !Array.isArray(rawOverrides)) { for (const [key, value] of Object.entries(rawOverrides as Record)) { @@ -748,6 +779,7 @@ function keymapSectionLines(rawOverrides: unknown): string[] { const line = `${tomlKey(key)} = ${tomlValue(value)}` lines.push(value === '' ? `${line} # unbound` : line) } + lines.push(...carriedEntries(carried, ['keymaps'], new Set(Object.keys(overrides)))) // The reference must render the same bytes on every OS. Launch rewrites the // file whenever its canonical text differs, so a reference built from this @@ -783,7 +815,7 @@ function keymapSectionLines(rawOverrides: unknown): string[] { * palette commands, then every bindable command as a commented reference. * Every command starts unbound, so the reference shows `""`: uncomment a * line and fill in a key. Static, so it reads the same on every OS (#901). */ -function commandKeymapSectionLines(rawBindings: unknown): string[] { +function commandKeymapSectionLines(rawBindings: unknown, carried: CarriedToml): string[] { const bindings: Record = {} if (rawBindings && typeof rawBindings === 'object' && !Array.isArray(rawBindings)) { for (const [key, value] of Object.entries(rawBindings as Record)) { @@ -806,6 +838,7 @@ function commandKeymapSectionLines(rawBindings: unknown): string[] { const line = `${tomlKey(key)} = ${tomlValue(value)}` lines.push(value === '' ? `${line} # unbound` : line) } + lines.push(...carriedEntries(carried, ['keymaps', 'commands'], new Set(Object.keys(bindings)))) lines.push('', '# --- All commands (uncomment + add a key to bind one) ---') for (const entry of COMMAND_CATALOG) { @@ -859,7 +892,13 @@ function readKeymapsTable(table: unknown): { /** Parse TOML text into a portable config object plus its version. Throws on * malformed TOML — callers fall back to the last good config. */ export function deserializeConfig(text: string): { version: number; portable: AppConfigPortable } { - const parsed = parseToml(text) as Record + return configFromToml(parseToml(text) as Record) +} + +function configFromToml(parsed: Record): { + version: number + portable: AppConfigPortable +} { const version = typeof parsed.config_version === 'number' ? parsed.config_version : 0 const portable: AppConfigPortable = {} @@ -908,6 +947,444 @@ function migrateConfig(_version: number, portable: AppConfigPortable): AppConfig return portable } +// --------------------------------------------------------------------------- +// Settings for other programs +// --------------------------------------------------------------------------- +// +// config.toml is shared with zn, the terminal app, which keeps settings of its +// own here: the [terminal] table, keys like `motions` under [vim]. This module +// rebuilds the file from the options it knows at every launch and settings +// change, so anything else used to vanish with each save. It is carried +// through instead, as the text it was (comments and all): a key goes back at +// the end of its table, a table the app does not write goes at the end of the +// file. A file that layout cannot carry (a dotted `vim.motions = ...` before +// any table, say) is carried as data instead, without its comments. Either +// way the result must read back with every carried value, or nothing is. + +/** A key the app does not write, as its lines plus the comments right above. */ +interface CarriedEntry { + key: string + text: string +} + +interface CarriedToml { + /** Keys inside tables the app writes, or before any table, by `tableId`. */ + entries: Map + /** Tables the app does not write, each as its text, in file order. */ + tables: string[] + /** False when the file did not parse as TOML: it was read around a broken + * table the app does not use, and a write is checked the same way. */ + parsed: boolean +} + +const NOTHING_CARRIED: CarriedToml = { entries: new Map(), tables: [], parsed: true } + +/** One string per table path. Joining with dots would make a table literally + * named "keymaps.commands" look like [keymaps.commands]. */ +function tableId(path: readonly string[]): string { + return JSON.stringify(path) +} + +const SECTION_KEYS = new Map>() +for (const map of [...Object.values(SCALAR_FIELDS), ...Object.values(LIST_FIELDS)]) { + if (!map) continue + const id = tableId([map.section]) + const keys = SECTION_KEYS.get(id) ?? new Set() + keys.add(map.tomlKey) + SECTION_KEYS.set(id, keys) +} +const KEYMAPS_ID = tableId(['keymaps']) +const COMMAND_KEYMAPS_ID = tableId(['keymaps', 'commands']) +const MAP_TABLE_IDS = new Set( + Object.values(MAP_TABLE_FIELDS).flatMap((def) => (def ? [tableId([def.table])] : [])) +) + +function isTomlTable(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) +} + +/** A table the app writes under its own `[header]`. */ +function isWrittenTable(path: readonly string[]): boolean { + const id = tableId(path) + return SECTION_KEYS.has(id) || MAP_TABLE_IDS.has(id) || id === COMMAND_KEYMAPS_ID +} + +/** Whether serializeConfig writes this key of this table. Keys of the map + * tables are the user's own, so it writes those whose value it can: strings, + * and numbers outside [keymaps]. */ +function writesEntry(table: readonly string[], key: string, value: unknown): boolean { + if (table.length === 0) return key === 'config_version' + const id = tableId(table) + const sectionKeys = SECTION_KEYS.get(id) + if (sectionKeys) return sectionKeys.has(key) + if (id === KEYMAPS_ID) return typeof value === 'string' || (key === 'commands' && isTomlTable(value)) + if (id === COMMAND_KEYMAPS_ID) return typeof value === 'string' + if (MAP_TABLE_IDS.has(id)) { + return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) + } + return false +} + +/** Everything in a parsed file that serializeConfig does not write. */ +function unwrittenData(table: Record, path: string[] = []): Record { + const out: Record = {} + for (const [key, value] of Object.entries(table)) { + const child = [...path, key] + if (isTomlTable(value) && isWrittenTable(child)) { + const inner = unwrittenData(value, child) + if (Object.keys(inner).length > 0) out[key] = inner + } else if (!writesEntry(path, key, value)) { + out[key] = value + } + } + return out +} + +function sameTomlData(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && Object.is(a.getTime(), b.getTime()) + } + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, i) => sameTomlData(value, b[i])) + ) + } + if (!isTomlTable(a) || !isTomlTable(b)) return false + const keys = Object.keys(a) + return ( + keys.length === Object.keys(b).length && + keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameTomlData(a[key], b[key])) + ) +} + +/** The carried keys of one table the app writes, minus any it now writes + * itself (a map entry the user has since set in the app). */ +function carriedEntries( + carried: CarriedToml, + table: readonly string[], + written: ReadonlySet +): string[] { + return (carried.entries.get(tableId(table)) ?? []) + .filter((entry) => !written.has(entry.key)) + .map((entry) => entry.text) +} + +function carryEntry(carried: CarriedToml, table: readonly string[], entry: CarriedEntry): void { + const id = tableId(table) + const list = carried.entries.get(id) + if (list) list.push(entry) + else carried.entries.set(id, [entry]) +} + +type TomlLine = + | { kind: 'blank'; text: string } + | { kind: 'comment'; text: string } + | { kind: 'table'; text: string; path: string[]; array: boolean } + | { kind: 'entry'; text: string; key: string } + +/** The key path a TOML key spells (`a."b.c"` is ["a", "b.c"]), or null. */ +function tomlKeyPath(keyText: string): string[] | null { + if (keyText.includes('\n')) return null + try { + const path: string[] = [] + let node: unknown = parseToml(`${keyText} = 0`) + while (isTomlTable(node)) { + const [key] = Object.keys(node) + if (key === undefined) return null + path.push(key) + node = node[key] + } + return path.length > 0 ? path : null + } catch { + return null + } +} + +/** Index of the first `=` outside a quoted key, or -1. */ +function keyValueSplit(text: string): number { + let quote = '' + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (quote) { + if (quote === '"' && ch === '\\') i++ + else if (ch === quote) quote = '' + } else if (ch === '"' || ch === "'") quote = ch + else if (ch === '=') return i + } + return -1 +} + +/** Where the key/value line starting at `start` ends: the newline that closes + * it, past any multi-line string or array. -1 when one is still open at the + * end of the file. */ +function entryEnd(text: string, start: number): number { + let i = start + let depth = 0 + let inValue = false + let quote = '' + while (i < text.length) { + const ch = text[i] + if (quote) { + if (quote.length === 3 && text.startsWith(quote, i)) { + // """ closes, and up to two more quotes before it belong to the string. + let run = 0 + while (text[i + run] === quote[0]) run++ + i += run + quote = '' + } else if (quote.length === 1 && ch === '\n') { + quote = '' // an unclosed one-line string: the line still ends here + } else { + i += quote[0] === '"' && ch === '\\' ? 2 : 1 + if (quote.length === 1 && ch === quote) quote = '' + } + continue + } + if (ch === '\n') { + if (depth === 0) return i + } else if (ch === '#') { + const eol = text.indexOf('\n', i) + i = eol < 0 ? text.length : eol + continue + } else if (ch === '"' || ch === "'") { + quote = text.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch + i += quote.length + continue + } else if (ch === '=') { + inValue = true + } else if (inValue && (ch === '[' || ch === '{')) { + depth++ + } else if (inValue && (ch === ']' || ch === '}')) { + depth = Math.max(0, depth - 1) + } + i++ + } + return quote.length === 3 || depth > 0 ? -1 : text.length +} + +function tableHeader(line: string): { path: string[]; array: boolean } | null { + const s = line.trim() + const array = s.startsWith('[[') + const open = array ? 2 : 1 + let quote = '' + let i = open + for (; i < s.length; i++) { + const ch = s[i] + if (quote) { + if (quote === '"' && ch === '\\') i++ + else if (ch === quote) quote = '' + } else if (ch === '"' || ch === "'") quote = ch + else if (ch === ']') break + } + let rest = s.slice(i + 1) + if (array) { + if (!rest.startsWith(']')) return null + rest = rest.slice(1) + } + rest = rest.trim() + if (i >= s.length || (rest && !rest.startsWith('#'))) return null + const path = tomlKeyPath(s.slice(open, i)) + return path ? { path, array } : null +} + +/** Split TOML text into blank and comment lines, table headers and key/value + * entries (an entry spans lines when its value does). It reads the layout, + * not the values, so it gets through a value that does not parse. Null when + * the layout is unclear: a string or array still open at the end, a line + * that is no key. */ +function scanTomlLines(source: string): TomlLine[] | null { + const text = source.replace(/\r\n/g, '\n') + const lines: TomlLine[] = [] + let pos = 0 + while (pos < text.length) { + let eol = text.indexOf('\n', pos) + if (eol < 0) eol = text.length + const line = text.slice(pos, eol) + const trimmed = line.trim() + if (trimmed === '' || trimmed.startsWith('#')) { + lines.push(trimmed === '' ? { kind: 'blank', text: line } : { kind: 'comment', text: line }) + pos = eol + 1 + continue + } + if (trimmed.startsWith('[')) { + const header = tableHeader(line) + if (!header) return null + lines.push({ kind: 'table', text: line, ...header }) + pos = eol + 1 + continue + } + const end = entryEnd(text, pos) + if (end < 0) return null + const entry = text.slice(pos, end) + const split = keyValueSplit(entry) + const key = split > 0 ? tomlKeyPath(entry.slice(0, split).trim())?.[0] : undefined + if (key === undefined) return null + lines.push({ kind: 'entry', text: entry, key }) + pos = end + 1 + } + return lines +} + +function entryValue(line: { text: string; key: string }): unknown { + try { + return (parseToml(line.text) as Record)[line.key] + } catch { + return undefined + } +} + +/** + * Sort scanned lines into what the app writes (`written`, the text a read + * that cannot parse the rest falls back to) and what it carries. Comment + * lines right above a key or a table go with it; a blank line ends them. + * Null when a key before any table writes into a table the app writes + * (`vim.motions = "helix"`, `vim = { ... }`): that text cannot be split. + */ +function splitConfigLines(lines: TomlLine[]): { written: string; carried: CarriedToml } | null { + const carried: CarriedToml = { entries: new Map(), tables: [], parsed: true } + const written: string[] = [] + let table: string[] = [] + let otherTable: string[] | null = null + let pending: TomlLine[] = [] + + const takePending = (): { before: string[]; comments: string[] } => { + let start = pending.length + while (start > 0 && pending[start - 1].kind === 'comment') start-- + const before = pending.slice(0, start).map((line) => line.text) + const comments = pending.slice(start).map((line) => line.text) + pending = [] + return { before, comments } + } + const closeOtherTable = (trailing: string[]): void => { + if (!otherTable) return + otherTable.push(...trailing) + while (otherTable.length > 0 && otherTable[otherTable.length - 1].trim() === '') otherTable.pop() + carried.tables.push(otherTable.join('\n')) + otherTable = null + } + + for (const line of lines) { + if (line.kind === 'blank' || line.kind === 'comment') { + pending.push(line) + } else if (line.kind === 'table') { + const { before, comments } = takePending() + closeOtherTable(before) + table = line.path + if (!line.array && isWrittenTable(line.path)) written.push(line.text) + else otherTable = [...comments, line.text] + } else if (otherTable) { + otherTable.push(...pending.map((pendingLine) => pendingLine.text), line.text) + pending = [] + } else { + const { comments } = takePending() + if (table.length === 0 && isWrittenTable([line.key])) return null + if (writesEntry(table, line.key, entryValue(line))) written.push(line.text) + else carryEntry(carried, table, { key: line.key, text: [...comments, line.text].join('\n') }) + } + } + closeOtherTable(pending.map((line) => line.text)) + return { written: written.join('\n'), carried } +} + +/** The same unwritten data as carried values, without the comments: for a + * file whose text splitConfigLines cannot carry. */ +function carriedFromData(unwritten: Record): CarriedToml { + const carried: CarriedToml = { entries: new Map(), tables: [], parsed: true } + const visit = (table: Record, path: string[]): void => { + for (const [key, value] of Object.entries(table)) { + const child = [...path, key] + if (isTomlTable(value) && isWrittenTable(child)) { + visit(value, child) + } else if (isTomlTable(value) || (Array.isArray(value) && value.length > 0 && value.every(isTomlTable))) { + // Nested under its full path: stringify writes [a.b] without an [a]. + const nested = child.reduceRight((inner, part) => ({ [part]: inner }), value) + carried.tables.push(stringifyToml(nested).trimEnd()) + } else { + carryEntry(carried, path, { key, text: stringifyToml({ [key]: value }).trimEnd() }) + } + } + } + visit(unwritten, []) + return carried +} + +/** What a file that parsed holds for other programs, as text when its layout + * allows, as data otherwise; nothing (with a warning) when neither writes + * back every value, which only a file the app could not write anyway does + * (`vim = 3`, say). */ +function carriedFromFile( + text: string, + parsed: Record, + portable: AppConfigPortable +): CarriedToml { + const unwritten = unwrittenData(parsed) + if (Object.keys(unwritten).length === 0) return NOTHING_CARRIED + const keepsEverything = (carried: CarriedToml): boolean => { + try { + const written = parseToml(serializeConfig(portable, carried)) as Record + return sameTomlData(unwrittenData(written), unwritten) + } catch { + return false + } + } + const lines = scanTomlLines(text) + const split = lines && splitConfigLines(lines) + if (split && keepsEverything(split.carried)) return split.carried + const fromData = carriedFromData(unwritten) + if (keepsEverything(fromData)) return fromData + console.warn('config.toml: could not keep these settings when rewriting it:', Object.keys(unwritten)) + return NOTHING_CARRIED +} + +/** + * Read config text: the options the app knows, or null when they do not + * parse, and what the file holds for other programs. A file that is not TOML + * still reads when the damage sits in a table or key the app does not write + * (a typo in zn's [terminal] must not cost the app its settings); that table + * is carried as it is, for its owner to fix. + */ +function readConfigText(text: string): { portable: AppConfigPortable | null; carried: CarriedToml } { + let parsed: Record + try { + parsed = parseToml(text) as Record + } catch { + const lines = scanTomlLines(text) + const split = lines && splitConfigLines(lines) + if (!split) return { portable: null, carried: NOTHING_CARRIED } + let portable: AppConfigPortable | null = null + try { + portable = configFromToml(parseToml(split.written) as Record).portable + } catch { + // Damage in the app's own options: no reading around that. + } + return { portable, carried: { ...split.carried, parsed: false } } + } + const { portable } = configFromToml(parsed) + return { portable, carried: carriedFromFile(text, parsed, portable) } +} + +/** The file text for these options plus what the file carries, checked to + * read back first: should it not, the carried part is left out. */ +function composeConfig(portable: AppConfigPortable, carried: CarriedToml): string { + const text = serializeConfig(portable, carried) + if (carried.entries.size === 0 && carried.tables.length === 0) return text + if (carried.parsed) { + try { + parseToml(text) + return text + } catch { + // falls through + } + } else if (readConfigText(text).portable) { + return text + } + console.warn('config.toml: settings for other programs did not fit back in; writing without them') + return serializeConfig(portable) +} + // --------------------------------------------------------------------------- // Cache + persistence + watching // --------------------------------------------------------------------------- @@ -970,7 +1447,11 @@ function clearRecheck(): void { const WATCH_DEBOUNCE_MS = 150 -async function readConfigFile(): Promise<{ text: string; portable: AppConfigPortable } | null> { +async function readConfigFile(): Promise<{ + text: string + portable: AppConfigPortable + carried: CarriedToml +} | null> { const file = getConfigFilePath() let text: string try { @@ -980,13 +1461,23 @@ async function readConfigFile(): Promise<{ text: string; portable: AppConfigPort console.warn('Failed to read config.toml', err) return null } - try { - const { portable } = deserializeConfig(text) - return { text, portable } - } catch (err) { - console.warn('Failed to parse config.toml, keeping last good config', err) + const { portable, carried } = readConfigText(text) + if (!portable) { + console.warn('Failed to parse config.toml, keeping last good config') return null } + return { text, portable, carried } +} + +/** What the file holds for other programs right now, read just before a + * write: zn may have saved since the app last read it. Kept even when the + * app's own options in it do not parse, since the write replaces those. */ +async function currentlyCarried(): Promise { + try { + return readConfigText(await fs.readFile(getConfigFilePath(), 'utf8')).carried + } catch { + return NOTHING_CARRIED + } } function debounce( @@ -1064,8 +1555,9 @@ export async function initAppConfig( // Normalize an existing file to the canonical, fully-documented form. Files // written by older versions (or hand-trimmed) may be missing options, // comments, or the example map tables — re-render so every option is shown. - // The user's set values are preserved; only presentation/missing keys change. - const canonical = serializeConfig(cache) + // The user's set values are preserved; only presentation/missing keys change, + // and what the file holds for other programs stays. + const canonical = composeConfig(cache, res.carried) if (canonical !== res.text) { lastKnownText = canonical rememberOwnWrite(canonical) @@ -1093,10 +1585,13 @@ export function getPortableConfigSnapshot(): AppConfigPortable { /** Merge a partial portable config into the cache and persist it atomically. */ export function setPortableConfig(partial: AppConfigPortable): Promise { cache = { ...cache, ...partial } - const text = serializeConfig(cache) + // This call's options, even if a watcher reload replaces the cache before + // the queued write runs. + const portable = cache writeQueue = writeQueue .catch(() => {}) .then(async () => { + const text = composeConfig(portable, await currentlyCarried()) if (text === lastKnownText) return // Set before writing so the watcher event this triggers is recognized // as our own and ignored. diff --git a/apps/desktop/src/main/database-domain.test.ts b/apps/desktop/src/main/database-domain.test.ts index 0b9dbbc3e..932c63c6a 100644 --- a/apps/desktop/src/main/database-domain.test.ts +++ b/apps/desktop/src/main/database-domain.test.ts @@ -7,6 +7,7 @@ import { inferFields, initialDatabaseContents, buildDefaultViews, + kanbanDatabaseSeed, type GenId } from '@shared/database-csv' import { @@ -224,6 +225,56 @@ describe('initialDatabaseContents', () => { expect(widths).toEqual([undefined, 120, undefined, undefined]) }) + it('New Kanban opens on a Board grouped by a To do / Doing / Done select (#1038)', () => { + const { sidecar, rows } = initialDatabaseContents(kanbanDatabaseSeed(), counterGenId()) + expect(rows).toEqual([]) + expect(sidecar.fields.map((f) => [f.name, f.type])).toEqual([ + ['id', 'text'], + ['Name', 'text'], + ['Status', 'select'] + ]) + const status = sidecar.fields[2] + expect(status.options?.map((o) => o.value)).toEqual(['To do', 'Doing', 'Done']) + expect(new Set(status.options?.map((o) => o.id)).size).toBe(3) + expect(sidecar.views.map((v) => [v.name, v.type])).toEqual([ + ['Board', 'board'], + ['Table', 'table'] + ]) + expect(sidecar.activeViewId).toBe(sidecar.views[0].id) + expect(sidecar.views[0].groupByFieldId).toBe(status.id) + expect(sidecar.views[1].hiddenFieldIds).toEqual([sidecar.idFieldId]) + expect(serializeRows(rows, sidecar.fields)).toBe('id,Name,Status\n') + }) + + it('a board seed adds the values its rows already hold after its columns', () => { + const { sidecar, rows } = initialDatabaseContents( + { + headers: ['Card', 'Stage'], + rows: [['Ship', ' Review '], ['Plan', 'Doing'], ['Fix', 'a, b']], + board: { groupBy: 'Stage', columns: ['To do', 'Doing', 'Doing', ' ', 'Done'] } + }, + counterGenId() + ) + const stage = sidecar.fields.find((f) => f.name === 'Stage')! + expect(stage.type).toBe('select') + expect(stage.options?.map((o) => o.value)).toEqual(['To do', 'Doing', 'Done', 'Review', 'a b']) + expect(rows.map((r) => r.cells[stage.id])).toEqual(['Review', 'Doing', 'a b']) + }) + + it('ignores a board seed that names no column of its own, or the row id', () => { + for (const board of [ + { groupBy: 'Missing', columns: ['A'] }, + { groupBy: 'id', columns: ['A'] } + ]) { + const { sidecar } = initialDatabaseContents( + { headers: ['id', 'Name'], rows: [], board }, + counterGenId() + ) + expect(sidecar.fields.every((f) => f.type === 'text')).toBe(true) + expect(sidecar.views.map((v) => v.type)).toEqual(['table']) + } + }) + it('a header-only seed makes an empty database with those fields', () => { const { sidecar, rows } = initialDatabaseContents( { headers: ['Title', 'Notes'], rows: [] }, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 5cb8adde1..0802646fa 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -7,6 +7,8 @@ import { ipcMain, Menu, nativeImage, + Notification, + powerMonitor, protocol, screen, session, @@ -21,6 +23,11 @@ import { randomUUID } from "node:crypto"; import { openAsBlob, promises as fsp, watch as watchPath } from "node:fs"; import { homedir, hostname } from "node:os"; import path from "node:path"; +import { + createTaskReminderScheduler, + type TaskReminderScheduler, +} from "./task-reminders"; +import type { TaskReminderTarget } from "@zennotes/bridge-contract/tasks"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { IPC } from "@shared/ipc"; @@ -106,6 +113,7 @@ import { restoreFromTrash, searchVaultTextCapabilities, searchVaultText, + writeFileAtomic, setVaultSettings, rootContentHiddenByInboxMode, type PersistedRemoteWorkspaceConfig, @@ -1074,6 +1082,58 @@ function revealTrayHiddenWindows(): void { void ensureMainWindow(); } +// Task reminders (#1036): the renderer computes the schedule, this process +// times it, so a window hidden in the tray still reminds. The notifications +// are kept referenced until clicked or closed, or a collected one would +// forget its click handler. +let taskReminders: TaskReminderScheduler | null = null; +const liveReminderNotifications = new Set(); + +/** A clicked reminder: bring the main window up and open the task (a folded + * notification opens the Tasks view). Without a window ready for it, the + * task's note opens the way a deep link would. */ +function openTaskReminder(target: TaskReminderTarget): void { + const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + if (win) { + trayHiddenWindowIds.delete(win.id); + focusWindow(win); + if (mainWindowReadyForAppEvents) { + win.webContents.send(IPC.TASK_REMINDER_OPEN, target); + return; + } + } + if (target) queueOpenNoteRequest(target.path); + else void ensureMainWindow(); +} + +function startTaskReminders(): void { + const statePath = path.join(app.getPath("userData"), "task-reminders.json"); + taskReminders = createTaskReminderScheduler({ + now: () => Date.now(), + notify: ({ title, body, target }) => { + if (!Notification.isSupported()) return; + const notification = new Notification({ title, body }); + liveReminderNotifications.add(notification); + const release = (): void => { + liveReminderNotifications.delete(notification); + }; + notification.on("click", () => { + release(); + openTaskReminder(target); + }); + notification.on("close", release); + notification.show(); + }, + readState: () => fsp.readFile(statePath, "utf8").catch(() => null), + writeState: (text) => writeFileAtomic(statePath, text), + setTimer: (fn, ms) => setTimeout(fn, ms), + clearTimer: (handle) => clearTimeout(handle as ReturnType), + }); + void taskReminders.start(); + powerMonitor.on("resume", () => taskReminders?.check()); + powerMonitor.on("unlock-screen", () => taskReminders?.check()); +} + function applyKeepRunningInTray(enabled: boolean): void { keepRunningInTray = enabled; if (!enabled) { @@ -5331,6 +5391,13 @@ function registerIpc(): void { app.quit(); }); + // The whole reminder schedule of one window (#1036). Untrusted like any + // renderer payload: the scheduler keeps only well-formed, vault-relative + // items, and only ever shows their text and hands their path back. + on(IPC.TASK_REMINDERS_SET, (event, raw: unknown) => { + taskReminders?.setSchedule(event.sender.id, raw); + }); + on(IPC.APP_TRAY_SYNC_STATUS, (event, raw: unknown) => { // One line for one app: the main window speaks for the tray, and a // second window's vault does not overwrite it. @@ -6396,6 +6463,7 @@ app.whenReady().then(async () => { installAppMenu(); registerIpc(); + startTaskReminders(); initAppUpdater(); watchInstalledBundle(); registerAppDeepLinkProtocol(); diff --git a/apps/desktop/src/main/task-reminders.test.ts b/apps/desktop/src/main/task-reminders.test.ts new file mode 100644 index 000000000..e3e5904e7 --- /dev/null +++ b/apps/desktop/src/main/task-reminders.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import type { TaskReminder } from '@zennotes/bridge-contract/tasks' +import { + createTaskReminderScheduler, + sanitizeReminders, + type ReminderNotice, + type TaskReminderHost +} from './task-reminders' + +const MIN = 60_000 +const HOUR = 60 * MIN + +function reminder(title: string, fireAt: number, path = 'Home.md'): TaskReminder { + return { key: `${path}\n${title}\n${fireAt}`, fireAt, title, body: `Due today · Home`, path, taskIndex: 0 } +} + +/** A host with a hand-turned clock and timers, and a file in memory. */ +function fakeHost(start: number, file: string | null = null) { + let now = start + let stored = file + let seq = 0 + const timers = new Map void }>() + const notices: ReminderNotice[] = [] + const host: TaskReminderHost = { + now: () => now, + notify: (n) => notices.push(n), + readState: async () => stored, + writeState: async (text) => { + stored = text + }, + setTimer: (fn, ms) => { + const id = ++seq + timers.set(id, { at: now + ms, fn }) + return id + }, + clearTimer: (id) => { + timers.delete(id as number) + } + } + /** Move the clock, firing timers in order as their time comes. */ + const advance = (ms: number): void => { + const until = now + ms + for (;;) { + const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0] + if (!next || next[1].at > until) break + timers.delete(next[0]) + now = next[1].at + next[1].fn() + } + now = until + } + return { host, notices, advance, stored: () => stored, setNow: (t: number) => (now = t) } +} + +const T0 = new Date(2026, 10, 1, 8, 0).getTime() + +describe('task reminder scheduler', () => { + it('fires each reminder once, on time', async () => { + const h = fakeHost(T0) + const s = createTaskReminderScheduler(h.host) + await s.start() + s.setSchedule(1, [reminder('Water', T0 + HOUR), reminder('Call', T0 + 2 * HOUR)]) + h.advance(HOUR - 1000) + expect(h.notices).toEqual([]) + h.advance(2000) + expect(h.notices.map((n) => n.title)).toEqual(['Water']) + expect(h.notices[0].target).toEqual({ path: 'Home.md', title: 'Water', taskIndex: 0 }) + // The renderer re-sends the schedule on every task change; no repeat. + s.setSchedule(1, [reminder('Water', T0 + HOUR), reminder('Call', T0 + 2 * HOUR)]) + h.advance(HOUR) + expect(h.notices.map((n) => n.title)).toEqual(['Water', 'Call']) + }) + + it('never fires a reminder set for a time already past', async () => { + const h = fakeHost(T0) + const s = createTaskReminderScheduler(h.host) + await s.start() + s.setSchedule(1, [reminder('Late', T0 - 10 * MIN)]) + h.advance(HOUR) + expect(h.notices).toEqual([]) + }) + + it('catches up on what came due while the app was quit, folded, at most a day back', async () => { + const first = fakeHost(T0) + const before = createTaskReminderScheduler(first.host) + await before.start() + before.setSchedule(1, [reminder('A', T0 + 2 * HOUR), reminder('B', T0 + 3 * HOUR)]) + await before.flush() + before.stop() + + // Relaunch the next day at 08:00, 24 h later: A and B came due meanwhile. + const later = T0 + 24 * HOUR + const second = fakeHost(later, first.stored()) + const after = createTaskReminderScheduler(second.host) + await after.start() + after.setSchedule(1, [reminder('A', T0 + 2 * HOUR), reminder('B', T0 + 3 * HOUR)]) + expect(second.notices.map((n) => [n.title, n.body, n.target])).toEqual([ + ['2 missed reminders', 'A, B', null] + ]) + }) + + it('an empty schedule at startup does not swallow the catch-up', async () => { + const first = fakeHost(T0) + const before = createTaskReminderScheduler(first.host) + await before.start() + before.setSchedule(1, [reminder('A', T0 + 2 * HOUR)]) + await before.flush() + before.stop() + const second = fakeHost(T0 + 5 * HOUR, first.stored()) + const after = createTaskReminderScheduler(second.host) + await after.start() + after.setSchedule(1, []) + second.advance(MIN) + after.setSchedule(1, [reminder('A', T0 + 2 * HOUR)]) + expect(second.notices.map((n) => n.title)).toEqual(['A']) + }) + + it('drops what is more than a day overdue', async () => { + const first = fakeHost(T0) + const before = createTaskReminderScheduler(first.host) + await before.start() + before.setSchedule(1, [reminder('Ancient', T0 + HOUR)]) + await before.flush() + before.stop() + const second = fakeHost(T0 + 3 * 24 * HOUR, first.stored()) + const after = createTaskReminderScheduler(second.host) + await after.start() + after.setSchedule(1, [reminder('Ancient', T0 + HOUR)]) + expect(second.notices).toEqual([]) + }) + + it('fires the last schedule after a tray-only launch with no window', async () => { + const first = fakeHost(T0) + const before = createTaskReminderScheduler(first.host) + await before.start() + before.setSchedule(1, [reminder('Water', T0 + 3 * HOUR)]) + await before.flush() + before.stop() + const second = fakeHost(T0 + HOUR, first.stored()) + const after = createTaskReminderScheduler(second.host) + await after.start() + second.advance(3 * HOUR) + expect(second.notices.map((n) => n.title)).toEqual(['Water']) + }) + + it('folds more than three on-time reminders into one', async () => { + const h = fakeHost(T0) + const s = createTaskReminderScheduler(h.host) + await s.start() + s.setSchedule(1, ['A', 'B', 'C', 'D'].map((t) => reminder(t, T0 + HOUR))) + h.advance(HOUR) + expect(h.notices.map((n) => [n.title, n.body])).toEqual([['4 task reminders', 'A, B and 2 more']]) + }) + + it('merges schedules from several windows and wakes at least hourly', async () => { + const h = fakeHost(T0) + const s = createTaskReminderScheduler(h.host) + await s.start() + s.setSchedule(1, [reminder('One', T0 + 5 * HOUR, 'a.md')]) + s.setSchedule(2, [reminder('Two', T0 + 6 * HOUR, 'b.md')]) + h.advance(6 * HOUR) + expect(h.notices.map((n) => n.title)).toEqual(['One', 'Two']) + }) + + it('a woken machine fires what it slept through', async () => { + const h = fakeHost(T0) + const s = createTaskReminderScheduler(h.host) + await s.start() + s.setSchedule(1, [reminder('Water', T0 + 30 * MIN)]) + // Asleep: no timer runs, the clock jumps, the host calls check on resume. + h.setNow(T0 + 5 * HOUR) + s.check() + expect(h.notices.map((n) => [n.title, n.target?.path])).toEqual([['Water', 'Home.md']]) + }) +}) + +describe('sanitizeReminders', () => { + it('keeps well-formed items and drops the rest', () => { + const good = reminder('Ok', T0) + expect( + sanitizeReminders([ + good, + { ...good, path: '../outside.md' }, + { ...good, path: '/abs.md' }, + { ...good, fireAt: Number.NaN }, + { ...good, taskIndex: -1 }, + { ...good, title: 'x'.repeat(600) }, + 'nope', + null + ]) + ).toEqual([good]) + expect(sanitizeReminders('nope')).toEqual([]) + expect(sanitizeReminders(new Array(600).fill(good))).toHaveLength(500) + }) +}) diff --git a/apps/desktop/src/main/task-reminders.ts b/apps/desktop/src/main/task-reminders.ts new file mode 100644 index 000000000..ff230018c --- /dev/null +++ b/apps/desktop/src/main/task-reminders.ts @@ -0,0 +1,248 @@ +// Task reminders on desktop (#1036): the main process times the schedule the +// renderer computes and shows each reminder as a system notification. +// +// The renderer owns WHAT to remind about (it has the task index, local or +// remote, and the daily-note dates); this module owns WHEN, because it is the +// one part of the app alive with the window hidden in the tray. It never +// touches the vault: a clicked reminder hands its path back to the renderer. +// +// Everything that has a clock or a side effect is injected, so the timing +// rules below are unit-tested without Electron. +// +// The rules: +// - A reminder fires once. A ledger of fired keys survives relaunches, so a +// renderer re-sending the schedule (it does on every task change) or a +// restart never repeats one. +// - "Watched until" is the last moment the app was checking. Anything that +// came due between it and now, at most 24 hours back, fires on the next +// check: what the app missed while quit, asleep or busy. Older ones drop, +// since the task already shows as due or overdue. +// - A reminder set for a time already past (written at 10:00 for 9:00) is +// behind "watched until" and never fires. +// - One timer, for the earliest unfired reminder, capped at an hour so sleep +// or a clock change cannot strand it; the host also checks on wake. + +import type { TaskReminder, TaskReminderTarget } from '@zennotes/bridge-contract/tasks' + +export const MAX_REMINDERS = 500 +const CATCH_UP_MS = 24 * 3_600_000 +const LATE_MS = 2 * 60_000 +const FIRED_TTL_MS = 7 * 86_400_000 +const MAX_WAIT_MS = 3_600_000 +const BOOT_GRACE_MS = 2 * 60_000 +const FOLD_OVER = 3 + +export interface ReminderNotice { + title: string + body: string + target: TaskReminderTarget +} + +export interface TaskReminderHost { + now(): number + notify(notice: ReminderNotice): void + readState(): Promise + writeState(text: string): Promise + setTimer(fn: () => void, ms: number): unknown + clearTimer(handle: unknown): void +} + +interface PersistedState { + version: 1 + watchedUntil: number + fired: Record + items: TaskReminder[] +} + +function isSafeRelPath(path: string): boolean { + if (!path || path.length > 1024 || path.includes('\0')) return false + if (path.startsWith('/') || /^[a-zA-Z]:/.test(path)) return false + return !path.split(/[\\/]/).includes('..') +} + +function boundedString(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length <= max && !value.includes('\0') +} + +/** The reminders in `raw` a renderer may hand over: well-formed, bounded, and + * pointing inside the vault. Anything else is dropped, not trusted. */ +export function sanitizeReminders(raw: unknown): TaskReminder[] { + if (!Array.isArray(raw)) return [] + const out: TaskReminder[] = [] + for (const item of raw.slice(0, MAX_REMINDERS)) { + if (!item || typeof item !== 'object') continue + const r = item as Record + if ( + !boundedString(r.key, 2048) || + typeof r.fireAt !== 'number' || + !Number.isFinite(r.fireAt) || + !boundedString(r.title, 500) || + !boundedString(r.body, 500) || + typeof r.path !== 'string' || + !isSafeRelPath(r.path) || + typeof r.taskIndex !== 'number' || + !Number.isInteger(r.taskIndex) || + r.taskIndex < 0 + ) { + continue + } + out.push({ + key: r.key, + fireAt: r.fireAt, + title: r.title, + body: r.body, + path: r.path, + taskIndex: r.taskIndex + }) + } + return out +} + +function parseState(text: string | null): PersistedState | null { + if (!text) return null + try { + const data = JSON.parse(text) as Partial + if (data?.version !== 1 || typeof data.watchedUntil !== 'number') return null + const fired: Record = {} + if (data.fired && typeof data.fired === 'object') { + for (const [key, at] of Object.entries(data.fired)) { + if (typeof at === 'number' && Number.isFinite(at)) fired[key] = at + } + } + return { version: 1, watchedUntil: data.watchedUntil, fired, items: sanitizeReminders(data.items) } + } catch { + return null + } +} + +export interface TaskReminderScheduler { + /** Load the ledger and the last schedule; call once at startup. */ + start(): Promise + /** A renderer's whole schedule. Each sender keeps its own, merged by key. */ + setSchedule(senderId: number, raw: unknown): void + /** Fire whatever came due. The host calls it on wake and unlock. */ + check(): void + /** Settles once the ledger on disk is current (before quitting). */ + flush(): Promise + stop(): void +} + +export function createTaskReminderScheduler(host: TaskReminderHost): TaskReminderScheduler { + const schedules = new Map() + // The schedule from the last run stands in until a renderer sends a fresh + // one, so a launch with no window (tray only) still fires what is due. + let persisted: TaskReminder[] = [] + let fired: Record = {} + let watchedUntil = host.now() + let started = false + let timer: unknown = null + let bootTimer: unknown = null + let writing = Promise.resolve() + + const merged = (): TaskReminder[] => { + const all = schedules.size > 0 ? [...schedules.values()].flat() : persisted + const byKey = new Map() + for (const r of all) if (!byKey.has(r.key)) byKey.set(r.key, r) + return [...byKey.values()].sort((a, b) => a.fireAt - b.fireAt) + } + + const persist = (): void => { + const state: PersistedState = { version: 1, watchedUntil, fired, items: merged() } + const text = JSON.stringify(state) + writing = writing.then(() => host.writeState(text)).catch(() => {}) + } + + const notifyAll = (due: TaskReminder[], now: number): void => { + const late = due.filter((r) => now - r.fireAt > LATE_MS) + const onTime = due.filter((r) => now - r.fireAt <= LATE_MS) + const one = (r: TaskReminder): ReminderNotice => ({ + title: r.title, + body: r.body, + target: { path: r.path, title: r.title, taskIndex: r.taskIndex } + }) + const folded = (list: TaskReminder[], title: string): ReminderNotice => { + const names = list.slice(0, 2).map((r) => r.title) + const more = list.length - names.length + return { + title, + body: more > 0 ? `${names.join(', ')} and ${more} more` : names.join(', '), + target: null + } + } + if (late.length === 1) host.notify(one(late[0])) + else if (late.length > 1) host.notify(folded(late, `${late.length} missed reminders`)) + if (onTime.length > FOLD_OVER) host.notify(folded(onTime, `${onTime.length} task reminders`)) + else for (const r of onTime) host.notify(one(r)) + } + + const arm = (): void => { + if (timer != null) host.clearTimer(timer) + timer = null + const next = merged().find((r) => r.fireAt > watchedUntil && !(r.key in fired)) + if (!next) return + const wait = Math.min(Math.max(next.fireAt - host.now(), 1000), MAX_WAIT_MS) + timer = host.setTimer(() => { + timer = null + check() + }, wait) + } + + const check = (): void => { + if (!started) return + const now = host.now() + const from = Math.max(watchedUntil, now - CATCH_UP_MS) + const due = merged().filter((r) => r.fireAt > from && r.fireAt <= now && !(r.key in fired)) + watchedUntil = now + for (const r of due) fired[r.key] = now + for (const [key, at] of Object.entries(fired)) if (now - at > FIRED_TTL_MS) delete fired[key] + if (due.length > 0) notifyAll(due, now) + persist() + arm() + } + + return { + async start() { + const state = parseState(await host.readState().catch(() => null)) + if (state) { + watchedUntil = Math.min(state.watchedUntil, host.now()) + fired = state.fired + persisted = state.items + } + started = true + if (schedules.size > 0) { + check() + return + } + // Give the window a moment to send a fresh schedule before catching up + // on the stale one; a fresh schedule checks at once. + bootTimer = host.setTimer(() => { + bootTimer = null + check() + }, BOOT_GRACE_MS) + }, + setSchedule(senderId, raw) { + const items = sanitizeReminders(raw) + schedules.set(senderId, items) + // An empty schedule during startup may be a window whose tasks are not + // indexed yet; checking on it would mark the stale schedule's missed + // reminders as watched. The boot timer catches up instead. + if (items.length === 0 && bootTimer != null) { + schedules.delete(senderId) + return + } + if (bootTimer != null) { + host.clearTimer(bootTimer) + bootTimer = null + } + check() + }, + check, + flush: () => writing, + stop() { + if (timer != null) host.clearTimer(timer) + if (bootTimer != null) host.clearTimer(bootTimer) + timer = bootTimer = null + started = false + } + } +} diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index 8397925d7..4a1013a05 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -13,6 +13,8 @@ import { replaceInBody, scanAllTasks, searchText, + toggleTask, + toggleTaskBody, toggleTaskInBody } from './vault-ops' import { TASK_LINE_RE as SHARED_TASK_LINE_RE } from '@shared/tasklists' @@ -142,6 +144,19 @@ describe('mcp task states', () => { expect(toggleTaskInBody(body, 1)).toBe('1. [ ] first\n> - [x] quoted\n') }) + it('toggle repeats a recurring task the way the app does (#1036)', async () => { + const body = '- [ ] first\n- [ ] Water @repeat:weekly due:2026-11-01\n' + expect(toggleTaskBody(body, 1, '2026-11-01')).toEqual({ + body: '- [ ] first\n- [ ] Water @repeat:weekly due:2026-11-08\n- [x] Water due:2026-11-01\n', + index: 2 + }) + expect(toggleTaskBody(body, 0, '2026-11-01')?.index).toBe(0) + await writeFile(path.join(root, 'inbox', 'Chores.md'), body) + const done = await toggleTask(root, 'inbox/Chores.md#1') + expect(done?.checked).toBe(true) + expect(done?.content).toBe('Water') + }) + it('toggle follows the app rules: [/] checks off, records stay (#599)', () => { const body = '- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n- [>] gone\n' expect(toggleTaskInBody(body, 0)).toContain('- [x] open') diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index ed2a456d6..a7ad193aa 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -22,7 +22,8 @@ import { retitleLeadingHeading } from '@shared/note-heading-sync' import { normalizeNoteComments } from '@shared/note-comments' import type { NoteComment, NoteCommentInput } from '@shared/ipc' export type { NoteComment, NoteCommentInput } -import { inlineTaskField, noteTasksMode, type NoteTasksMode } from '@shared/tasks' +import { inlineTaskField, noteTasksMode, toIsoDateLocal, type NoteTasksMode } from '@shared/tasks' +import { completeRecurringTaskAtIndex } from '@shared/task-recurrence' import { isPathExcludedFromTasks, normalizeTasksExcludedFolders, @@ -1998,8 +1999,9 @@ export async function toggleTask(root: string, taskId: string): Promise#" into its halves. `indexStr` is the literal diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 3cbae3178..f923e0773 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -57,6 +57,7 @@ import type { WriteWorkflowInput } from '@zennotes/bridge-contract/workflows' import { IPC } from '@shared/ipc' +import type { TaskReminder, TaskReminderTarget } from '@zennotes/bridge-contract/tasks' import type { AppConfigPortable } from '@shared/app-config' import type { ExternalUrlResult } from '@shared/application-links' import type { CustomTheme } from '@shared/custom-themes' @@ -779,6 +780,13 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.WINDOW_HIDE_TO_TRAY), reportTraySyncStatus: (status: TraySyncStatus | null): void => ipcRenderer.send(IPC.APP_TRAY_SYNC_STATUS, status), + setTaskReminders: (reminders: TaskReminder[]): void => + ipcRenderer.send(IPC.TASK_REMINDERS_SET, reminders), + onTaskReminderOpen: (cb: (target: TaskReminderTarget) => void): (() => void) => { + const listener = (_: unknown, target: TaskReminderTarget): void => cb(target) + ipcRenderer.on(IPC.TASK_REMINDER_OPEN, listener) + return () => ipcRenderer.removeListener(IPC.TASK_REMINDER_OPEN, listener) + }, quitApp: (): void => ipcRenderer.send(IPC.APP_QUIT), renderTikz: (source: string): Promise<{ ok: boolean; svg?: string; error?: string }> => ipcRenderer.invoke(IPC.TIKZ_RENDER, source), diff --git a/apps/desktop/src/renderer/excalidraw-asset-path.ts b/apps/desktop/src/renderer/excalidraw-asset-path.ts new file mode 100644 index 000000000..3d765bc41 --- /dev/null +++ b/apps/desktop/src/renderer/excalidraw-asset-path.ts @@ -0,0 +1,5 @@ +// Where Excalidraw loads its bundled fonts from: main serves the +// `zen-excalidraw:` protocol from resources/excalidraw-fonts (#324). Shared by +// the renderer, which sets EXCALIDRAW_ASSET_PATH to it, and by +// electron.vite.config.ts, which points Excalidraw's CDN fallback at it too. +export const EXCALIDRAW_ASSET_PATH = 'zen-excalidraw://assets/' diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 47f4cdfa8..94a2e1f61 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -1,12 +1,13 @@ import { installZenBridge } from '@zennotes/bridge-contract/bridge' import { renderZenNotesApp } from '@zennotes/app-core/main' import { renderExportNoteWindow } from './export-window' +import { EXCALIDRAW_ASSET_PATH } from './excalidraw-asset-path' // Point Excalidraw's font loader at our local, CSP-allowed protocol instead of // its default esm.sh CDN (which the renderer CSP blocks, so no fonts applied). // Must be set before the lazy Excalidraw bundle loads. (#324) const excalidrawGlobal = window as unknown as { EXCALIDRAW_ASSET_PATH?: string } -excalidrawGlobal.EXCALIDRAW_ASSET_PATH = 'zen-excalidraw://assets/' +excalidrawGlobal.EXCALIDRAW_ASSET_PATH = EXCALIDRAW_ASSET_PATH const root = document.getElementById('root') diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 5bbf9ede0..21e71bb17 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.13.1", - "commit": "2df43e938cd5567ba3a759cc5f81c08c9eca496d", + "version": "0.14.0", + "commit": "072a386691fcf679649a40af0e73642ffe578913", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_darwin_arm64.tar.gz", - "sha256": "f3c9f9ec75e93a7ce975e8acb099cec5c4889f38ab2e6a39a2159c7132e62d37" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.14.0/zn_0.14.0_darwin_arm64.tar.gz", + "sha256": "69c63952d706c3c19296bef46ca479d4f92bb1cd3ebed1dbd4bc6af77e18d69d" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_darwin_amd64.tar.gz", - "sha256": "3aac2fb65558760a861a10a9caa8eab892d34cbdcf52230e83aa80ba6737ef04" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.14.0/zn_0.14.0_darwin_amd64.tar.gz", + "sha256": "ec2ebb3af08a6df442b8de3a24ced7d1d68b75b8259650a0544385ca59708731" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_linux_arm64.tar.gz", - "sha256": "90c3aa7463a1de2d106b31b20474ac753d4c4747a52539a753c2973b492cf448" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.14.0/zn_0.14.0_linux_arm64.tar.gz", + "sha256": "bd56c85ec3c7eac2dcdd6f3b8631c86e68f79e9c91e5459c47aa9fd5d022ffa0" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_linux_amd64.tar.gz", - "sha256": "3ea08b4728c36384e8ccb008e29daa8b7ebfeb4c5264497966d41039fb326aa1" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.14.0/zn_0.14.0_linux_amd64.tar.gz", + "sha256": "cb6db04e4dfee155b690ef457a2435a2fc22de2352a34e3c9a870ae14b63b639" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index d023a7946..50b907790 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index eb348331f..eab81a0d6 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 1e044a1e5..70925cad3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/docs/ideas/recurring-tasks-mockup.html b/docs/ideas/recurring-tasks-mockup.html new file mode 100644 index 000000000..5065d32c2 --- /dev/null +++ b/docs/ideas/recurring-tasks-mockup.html @@ -0,0 +1,270 @@ + + + + + +Recurring Tasks Mockup + + + +
+

Recurring tasks and reminders (#1036)

+

Mockup for the design in docs/ideas/recurring-tasks.md. Default light theme; nothing here is built yet.

+ +
+
+

1 · In the editor (live preview)

+
+
Home2026-11-01
+
+

Home

+

Chores

+
Water the plants + @repeat:weeklydue:2026-11-08@remind:9am
+
Water the plantsdue:2026-11-01
+
Weekly review + @repeat:fridue:2026-11-060/2
+
Inbox zero
+
Plan the week
+
Weekly reviewdue:2026-10-302/2
+
Pay rentdue:2026-12-01@repeat: + + monthlyDec 1 → Jan 1 + weeklyevery 7 days + dailyevery day + weekdaysMon to Fri + yearlyevery year + 2wevery 2 weeks + after-3d3 days after done +
+
+
+
+

Tokens stay as typed, styled like due:. Typing @repeat: or @remind: offers the values. Vim: :repeat weekly, :remind 9am.

+
+ +
+

2 · What checking it off writes

+
+
Home.md (on disk)
+
  ## Chores- - [ ] Water the plants @repeat:weekly due:2026-11-01 @remind:9am+ - [ ] Water the plants @repeat:weekly due:2026-11-08 @remind:9am+ - [x] Water the plants due:2026-11-01   A checklist repeats whole; the record keeps its ticks:- - [ ] Weekly review @repeat:fri due:2026-10-30-   - [x] Inbox zero-   - [x] Plan the week+ - [ ] Weekly review @repeat:fri due:2026-11-06+   - [ ] Inbox zero+   - [ ] Plan the week+ - [x] Weekly review due:2026-10-30+   - [x] Inbox zero+   - [x] Plan the week
+
+

The next one goes above, so the open task stays under the cursor. The record drops @repeat and @remind, so unchecking it never spawns a second copy. Finished late? The next date skips to the first one after today.

+
+ +
+

3 · In the Tasks view (list), with the Repeat menu open (r)

+
+
TasksHome
+
+
ListCalendarKanban
+ Filter tasks… + OrderNote order ▾ + View ▾ + ⌨ +
+
+
▾Today21 overdue
+
Take out the recycling
Home
+
↻Oct 31
+
Call Mum
Family
+
↻⏰ 6:00 PMNov 1 + xcheckrrepeat⏎open
+ + + +
▾Upcoming3
+
Weekly review
Home · 0/2
+
↻Nov 6
+
Water the plants
Home
+
↻⏰ 9:00 AMNov 8
+
Pay rent
Finance
+
High↻⏰ day beforeDec 1
+ +
▾Done1
+
Water the plants
Home
+
Nov 1
+ +
Water the plants · next: Sun, Nov 8Undo
+
+
+

One quiet ↻ beside the due date (the rule is in its tooltip) and ⏰ when a reminder is set; no new toolbar controls. R opens Remind…; both are also in the task menu (m, Shift+F10) with “Skip this time”. With Vim off, only the menu and the palette offer them.

+
+ +
+

4 · The reminder (macOS notification)

+
+
Z
+
ZENNOTESnow
+
Water the plants
+
Due today · Home
+
Z
+
ZENNOTES9:41 AM
+
3 reminders while ZenNotes was closed
+
Pay rent, Water the plants and 1 more
+
+

Fired by the main process from the schedule the app keeps. A click focuses ZenNotes (out of the tray too) and opens the task. Missed ones from the last 24 h fire once at the next launch, folded together.

+
+ +
+

5 · Settings → Tasks

+
+
+
Reminders
+
Task remindersNotify for tasks with @remind.
+
Reminder timeFor day-before reminders.
09:00
+
+
+

Two one-line rows, both portable ([tasks] in config.toml). A Daily summary row comes in slice 2.

+
+
+
+ + diff --git a/docs/ideas/recurring-tasks.md b/docs/ideas/recurring-tasks.md new file mode 100644 index 000000000..0ec6ddf14 --- /dev/null +++ b/docs/ideas/recurring-tasks.md @@ -0,0 +1,235 @@ +# Recurring Tasks and Reminders + +Tasks that come back on a schedule, and a nudge before one is due +([#1036](https://github.com/ZenNotes/zennotes/issues/1036), by @williamcheuk03). + +> **Status (November 2026).** Slice 1 is built (recurrence for checkbox tasks +> on every app surface and MCP, desktop reminders), not yet released. Slices 2 +> and 3 below are still design. Written from an inventory of the task system on +> `v2.81.0` and of Obsidian Tasks, TaskNotes and obsidian-reminder. Mockup: +> `recurring-tasks-mockup.html` beside this file (drawn before the build; see +> "Built differently" at the end). + +## What exists today + +- **Task line.** `- [ ]` with five states (` ` open, `/` in progress, `x` done, + `>` forwarded, `-` cancelled) and four tokens: `due:YYYY-MM-DD`, `!high|med|low`, + `@waiting`, `@key:value` fields (#354), plus `#tags`. Dates are date-only. +- **Four synced parsers.** `packages/shared-domain/src/tasks.ts` (normative), the + MCP copy in `apps/desktop/src/mcp/vault-ops.ts`, `zn` in + `zennotescli/internal/vault/tasks.go`, and `znserver/internal/vault/parse.go`. + All four already read `@key:value`; the app and Go strip it from the display + text, MCP and `zn` leave it in. +- **Daily notes.** `tasksDueOnNoteDate` gives a task its note's date; rollover + carries open task blocks into today; `moveTaskToDate`; forwarding with its + subtree (#611). `taskBlockEnd` is the one definition of a task's subtree. +- **File tasks** (TaskNotes style): `tags: [task]`, `status`, `due`, `scheduled`, + `completedDate`, `priority` in frontmatter. +- **Every way a task gets checked off:** the store's `toggleTaskFromList` (Tasks + list, Home, Calendar panel, Timeline, the task menu), `applyTaskMutation` + `set-checked` (a Kanban drop into Done), editor `Mod+L`, the live-preview + checkbox widget, Preview's checkbox click, the phone Calendar widget's + precomputed `toggled` line, MCP `toggle_task`, `zn task toggle` and the TUI `x`. + The Go server has no toggle route: clients write the note. +- **Recurrence:** none. **Notifications:** only the updater uses Electron's + `Notification`. **Background:** Keep running in tray hides the window instead + of closing it. + +## Syntax + +Two new `@key:value` fields. Riding the field grammar means none of the four +parsers changes to read or strip them, an Obsidian user sees plain text, and a +value is case-insensitive (parsers lower-case fields). + +``` +- [ ] Water the plants @repeat:weekly due:2026-11-01 @remind:9am +``` + +### `@repeat:` + +| Rule | Next date | +| --- | --- | +| `daily`, `weekly`, `monthly`, `yearly` | one day, week, month or year on | +| `weekdays` | the next Monday to Friday | +| `2d`, `3w`, `6m`, `1y` | every N days, weeks, months, years | +| `mon`, `mon/thu` | the next of those weekdays | +| `after-3d` (any interval) | N after the day you finish it | + +An unknown rule draws as a dim chip and the task checks off like any other. + +### `@remind:` + +| When | Fires | +| --- | --- | +| `9am`, `2pm`, `930am`, `0930`, `1730` | at that time on the due date | +| `1d`, `2d` | N days before, at the default reminder time (09:00) | +| `1d-6pm` | N days before, at that time | + +A reminder needs a due date, written or taken from the daily note. Without one +it is inert (dim chip). Times display in the app's 12h/24h format. + +### Why not Obsidian Tasks' `🔁 every week` + +Obsidian Tasks writes its dates as emoji too (`📅 2026-11-01`), and ZenNotes +reads none of them, so its rule alone would be half a format. A vault shared +both ways stays safe: Tasks ignores `@repeat:`, and ZenNotes ignores `🔁`. +Reading the emoji format is its own feature; if it lands, `🔁 every ` maps +onto the same rule model. + +## Checking off a recurring task + +```diff +- - [ ] Water the plants @repeat:weekly due:2026-11-01 @remind:9am ++ - [ ] Water the plants @repeat:weekly due:2026-11-08 @remind:9am ++ - [x] Water the plants due:2026-11-01 +``` + +1. **When.** On open (`[ ]` or `[/]`) to done, from any app surface, MCP, and + `zn` once mirrored. Typing an `x` into the brackets by hand is a text edit, + not a command, and does not repeat (Obsidian Tasks draws the same line). +2. **The next occurrence goes directly above** the finished line, with the same + indent and list marker, a fresh `[ ]`, and the next date in `due:`. That is + Obsidian Tasks' default order, and the open task stays where the cursor and + the list cursor already are. Per-occurrence state does not carry over: + `@waiting` and `@status:` drop, so the next one starts in the board's first + column. +3. **Subtasks repeat too.** The task's subtree is copied under the new line + with every checkbox reset to `[ ]`. The finished line keeps its subtree as + the record of that week, ticks and all, and cancels (`[-]`) the subtasks + left open, so only the fresh copies read as live work. Subtasks selected + along with the task (`Vjj` then `Mod+L`) are written done instead. +4. **The finished line is a record.** It drops `@repeat` and `@remind`, the two + tokens that only mean something on open work. Obsidian Tasks keeps the rule; + we drop it so unchecking the record, or dragging a Done card back and forth, + never spawns a second copy. +5. **The next date** counts from the due date (written, else the daily note's, + else today) and is the first date of the rule after both the due date and + today. A daily task finished five days late gets tomorrow, not five overdue + copies. Obsidian Tasks does not skip; we do, on purpose. `after-` rules count + from today. +6. **Month ends clamp.** `monthly` from Jan 31 gives Feb 28; the line then only + knows the 28th. Documented; a `monthly-last` rule can come later. +7. **Daily notes.** The next occurrence stays in the same note with an explicit + `due:`. Rollover then carries it like any open task. Writing it into the + target day's note instead would create future daily notes nobody asked for. +8. **Skip this time** (task menu) writes the record as `[-]` cancelled and + schedules the next one. **Cancel** on a recurring task ends the series. +9. **Undo.** One `Mod+Z` in the editor. In the Tasks views a toast reads + "Next: Sun, Nov 8" with Undo, through the toast's existing action slot. +10. **File tasks (slice 2), TaskNotes compatible.** The note stays one file. + ZenNotes reads TaskNotes' `recurrence:` RRULE (FREQ, INTERVAL, BYDAY, + optional DTSTART) and `recurrence_anchor`. Finishing appends the date to + `complete_instances`, advances `scheduled` and `due`, and leaves `status` + open. Skip appends to `skipped_instances`. Setting a repeat on a file task + writes `recurrence:`, never a ZenNotes-only key. + +## Reminders on desktop + +``` +renderer: vaultTasks ─▶ computeTaskReminders() ─▶ zen.setTaskReminders(list) +main: validate ─▶ userData/task-reminders.json ─▶ one timer ─▶ Notification +click: focus window ─▶ IPC open-reminder ─▶ store.openTaskAt(task) +``` + +- **One pure function** in shared-domain, `computeTaskReminders(tasks, now, + prefs)`, turns open tasks with `@remind:` into `{ key, fireAt, title, body, + path }`. The renderer runs it on the task index it already keeps (debounced, + next 7 days), so local and remote vaults behave the same. +- **Main schedules and fires.** `task-reminders.ts` validates the payload (at + most 500 items, bounded strings, finite times, vault-relative paths), persists + it, and arms one timer for the earliest unfired item, re-armed after each fire + and capped at an hour so sleep and clock changes cannot strand it. It + re-checks on `powerMonitor` resume and unlock. Notifications are retained + until closed so their click handler survives garbage collection. +- **What shows.** Title: the task text. Body: "Due today · Home" (or "Due + tomorrow"). Click: show and focus the window (out of the tray too) and open + the task. More than three at once fold into one: "4 task reminders". +- **Fire once.** A ledger of fired keys (path, task text, fire time) stops a + re-send or a relaunch from repeating a reminder; entries older than 7 days are + pruned. Done, cancelled and forwarded tasks never fire. +- **Settings → Tasks**, one line each, all portable (`[view]` in config.toml, + `task_reminders` and `task_reminder_time`): Task reminders (on), Reminder + time (09:00), and in slice 2 Daily summary (off). + +### While the app is closed + +- **Quit:** nothing fires. Electron has no scheduled OS notification API, and a + native one per platform is not worth it while the phones can do it properly. +- **On the next launch,** every reminder whose time fell between the last + moment ZenNotes was watching and now, up to 24 hours back, fires once, folded + into "3 reminders while ZenNotes was closed". Older ones drop: the task + already shows as due or overdue. TaskNotes does the same 24-hour catch-up. +- **Window closed, Keep running in tray:** reminders fire on time. +- **Asleep:** on wake, anything missed (within 24 h) fires at once. + +## Phones, web, server, CLI, MCP + +| Surface | Recurrence | Reminders | +| --- | --- | --- | +| Desktop | slice 1 | slice 1 | +| Self-hosted web client | free with app-core (the client writes the note) | later: Notification API while a tab is open | +| iPhone and Android apps | free when they adopt the core; the Calendar widget gets a two-line `toggled` (next + record), or opens the app when the task has subtasks | slice 3: OS local notifications, which fire with the app closed (iOS caps pending ones at 64) | +| MCP `toggle_task` | imports the shared function | none | +| `zn` CLI + TUI | Go mirror of the completion edit, slice 2 | none | +| znserver | nothing: parsing already reads the fields, writes come from clients | none (an ICS feed with alarms is a later idea) | + +The completion edit gets one table of cases (JSON) that the TypeScript and Go +tests both run, so the two implementations cannot drift. + +## Keyboard + +- **Tasks list, board, calendar (Vim on):** `r` Repeat…, `R` Remind…, also in + the task menu (`m`, Shift+F10) with the keys as hints. Neither letter is bound + on its own in those views today (the board's `zR` is a two-key sequence). +- **Vim off:** the same two entries in the task menu (Shift+F10, Context Menu + key) and the command palette ("Task: Repeat…", "Task: Remind…"). +- **Editor:** `:repeat weekly`, `:repeat off`, `:remind 9am`, `:remind off` act + on the cursor's task line; typing `@repeat:` or `@remind:` offers the values. +- **Pickers:** Daily, Weekdays, Weekly, Monthly, Yearly, Custom…, Stop + repeating; Morning of (09:00), Day before, Custom…, No reminder. + +## Display + +- **Editor (live preview):** the tokens stay as typed and get the chip styling + `due:` already has, in their own colors (aqua for `@repeat:`, yellow for + `@remind:`), so the source never hides behind a widget. +- **Tasks list and board:** one quiet `↻` chip beside the due date (the rule in + its tooltip) and `⏰ 9:00` when a reminder is set. No new toolbar controls. +- **Kanban:** `repeat` and `remind` are not offered as group-by fields. + +## Slices + +1. **Recurrence for inline tasks + desktop reminders.** The shared rule parser, + next-date math and completion edit (line + subtree) with tests; every app + check-off path; Skip this time; MCP; chips, pickers, `r`/`R`, ex commands, + completions; `@remind:` scheduling, notifications, catch-up, two Settings + rows; help.ts + website docs. +2. **File tasks (TaskNotes recurrence and `reminders:`), the `zn` mirror, + Daily summary.** +3. **Phones:** core adoption, widget toggle, local notifications. + +## Open questions + +1. `@repeat:` / `@remind:` fields (no parser changes) versus dedicated + `repeat:` / `remind:` tokens like `due:` (four parser changes, but `remind:09:00` + could carry a colon). +2. Drop `@repeat` from the finished line (proposed) or keep it like Obsidian Tasks. +3. Skip missed occurrences (proposed) or step one interval like Obsidian Tasks. +4. Is slice 1 the right first cut, or recurrence alone first? + +## Built differently from the mockup + +- The Tasks views mark a recurring task with small stroke icons (repeat arrows, + a bell and the time), not the `↻` / `⏰` glyphs: the alarm clock drew as a + red color emoji. +- Repeat… and Remind… are the app's prompt with preset suggestions, not a + menu: the presets are one Enter away and any other rule types in place. +- From the Tasks views a new rule lands at the end of the line, like every + other list edit (due, priority, `@status:`); `:repeat` in the editor + replaces it where it stands. +- `x` in the Tasks list does not linger a recurring task's row: the row is the + next occurrence by then, and a second `x` would check that one off. The + toast's Undo is the way back. +- The record cancels the subtasks left open (Adib, 2026-11-01), so they no + longer stand on the Tasks list beside the next occurrence's fresh copies. + diff --git a/package-lock.json b/package-lock.json index 7ee0529a9..03c06a7c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.80.1", + "version": "2.81.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.80.1", + "version": "2.81.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.80.1", + "version": "2.81.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.80.1", + "version": "2.81.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.80.1", + "version": "2.81.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.80.1", + "version": "2.81.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16396,7 +16396,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.80.1", + "version": "2.81.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16485,14 +16485,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.80.1", + "version": "2.81.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.80.1", + "version": "2.81.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16506,7 +16506,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.80.1" + "version": "2.81.0" } } } diff --git a/package.json b/package.json index 190e407f4..38415e079 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.80.1", + "version": "2.81.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 0c37a903d..fe2da4138 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -221,6 +221,7 @@ host's stable vault token. ### Folder and database actions - `createBrowseDatabase(host, directory?)` creates and opens an untitled database. Omitting the directory uses the configured database location, including the active note's folder. Explicit `''` selects the primary root; any other explicit directory must be an existing ordinary Browse folder. +- `createBrowseKanban(host, directory?)` is New Kanban: it creates and opens a database that opens on its Board, with To do, Doing and Done columns, placed exactly as `createBrowseDatabase` places one. With a directory it is the desktop folder menu's New Kanban. - `requestRenameBrowseDatabase(host, directory)` prompts for a database title and preserves host collision numbering. Names starting with a dot are rejected because vault scanners hide those directories. Case-only renames retain existing host behavior and can receive a numbered suffix on case-insensitive filesystems. - `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. - `requestCreateBrowseNote(host, directory = '')` creates a note in an ordinary Browse folder (`''` is the primary root) and opens it with its title focused, without a prompt. A missing folder or a database is refused with `unavailable`. diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 83c94f293..8980847a1 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 0c0ed840d..5b0c77aee 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -66,6 +66,7 @@ import { } from './lib/app-update-state' import { ensureCloudAutoSyncStarted, stopCloudAutoSync } from './lib/cloud-auto-sync' import { loadKeepRunningInTray, startTraySyncStatusReporter } from './lib/tray-runtime' +import { listenForTaskReminderOpen, startTaskReminderReporter } from './lib/task-reminders-runtime' import { installHarperRuntime } from './lib/harper-runtime' import { installIgnoredKeysGuard } from './lib/ignored-keys' import { installCommandKeymapDispatcher, isMainAppWindow } from './lib/command-keymap-dispatch' @@ -433,6 +434,15 @@ function App(): JSX.Element { } }, []) useEffect(() => installHarperRuntime(), []) + // Task reminders (#1036): the schedule goes to the host, clicks come back. + useEffect(() => { + const stopReporting = startTaskReminderReporter() + const stopListening = listenForTaskReminderOpen() + return () => { + stopReporting() + stopListening() + } + }, []) useEffect(() => { if (!vault) return undefined @@ -1225,11 +1235,20 @@ function App(): JSX.Element { // A window-drag strip belongs to the window, not the page, so a tab or note // dragged inside the app may not be able to drop on one (the blank end of a // tab strip with the title bar off). Nothing moves the window mid-drag, so - // the strips stand down until the drag ends. + // the strips stand down until the drag ends. A dragstart that something + // cancels later in its dispatch (the image resize handle does, and so do + // the phones' long-press guards) never sends a dragend, which left the + // strips down and the title bar no longer moving the window, so once the + // dispatch is over a cancelled start ends the drag here. useEffect(() => { const root = document.documentElement - const start = (): void => root.classList.add('zn-app-dragging') const end = (): void => root.classList.remove('zn-app-dragging') + const start = (event: DragEvent): void => { + root.classList.add('zn-app-dragging') + window.setTimeout(() => { + if (event.defaultPrevented) end() + }, 0) + } window.addEventListener('dragstart', start, true) window.addEventListener('dragend', end, true) window.addEventListener('drop', end, true) diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index 386aea094..effd63e44 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -20,6 +20,7 @@ async function setup() { const rename = vi.fn(async () => {}) const remove = vi.fn(async () => {}) const createDatabase = vi.fn(async () => undefined) + const createKanban = vi.fn(async () => {}) const renameDatabase = vi.fn(async () => {}) const refreshAssets = vi.fn(async () => {}) const createAndOpen = vi.fn(async () => {}) @@ -34,6 +35,7 @@ async function setup() { renameFolder: rename, deleteFolder: remove, createDatabase, + createKanban, renameDatabase, refreshAssets, createAndOpen @@ -58,6 +60,7 @@ async function setup() { rename, remove, createDatabase, + createKanban, renameDatabase, refreshAssets, createAndOpen, @@ -257,6 +260,25 @@ describe('public Browse actions', () => { expect(await s.createBrowseDatabase(s.host, 'Missing')).toBe('unavailable') }) + it('creates a Kanban in a Browse folder or where new databases go, as a folder menu does (#1038)', async () => { + const s = await setup() + expect(await s.createBrowseKanban(s.host, 'Work')).toBe('completed') + expect(s.createKanban).toHaveBeenLastCalledWith('inbox', 'Work', expect.any(Function)) + expect(s.getPromptRequest()).toBeNull() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + databasesLocation: { mode: 'folder', folder: 'Databases' } + } + }) + expect(await s.createBrowseKanban(s.host)).toBe('completed') + expect(s.createKanban).toHaveBeenLastCalledWith('inbox', 'Databases', expect.any(Function)) + expect(await s.createBrowseKanban(s.host, 'People.base')).toBe('unavailable') + expect(await s.createBrowseKanban(s.host, 'Missing')).toBe('unavailable') + expect(s.createKanban).toHaveBeenCalledTimes(2) + expect(s.createDatabase).not.toHaveBeenCalled() + }) + it.each([false, true])('renames a database with primary root mode %s', async (root) => { const s = await setup() s.useStore.setState({ diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts index dd316e58f..356038454 100644 --- a/packages/app-core/src/browse.ts +++ b/packages/app-core/src/browse.ts @@ -276,6 +276,7 @@ export function getBrowseDirectory( export { createBrowseDatabase, + createBrowseKanban, openBrowseFolderGallery, requestRenameBrowseDatabase, requestCreateBrowseFolder, diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts index ece0e1ef6..a19771e70 100644 --- a/packages/app-core/src/components/ContextMenu.test.ts +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -351,3 +351,60 @@ describe('ContextMenu with a find field (#1014)', () => { expect(field()).not.toBeNull() }) }) + +describe('ContextMenu key hints on a touch-first device', () => { + let host: HTMLDivElement + let root: Root + const original = Object.getOwnPropertyDescriptor(window, 'matchMedia') + + const pointer = (coarse: boolean): void => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string) => ({ matches: coarse && query === '(pointer: coarse)', addEventListener() {}, removeEventListener() {} }) + }) + } + const sides = async (): Promise => { + await act(async () => + root.render( + createElement(ContextMenu, { + x: 10, + y: 10, + items: [ + { label: 'Open page', hint: 'Enter', onSelect: () => {} }, + { label: 'Text', mark: '●', onSelect: () => {} }, + { label: 'Due today', mark: '2026-11-01', hint: 'never shown', onSelect: () => {} } + ], + onClose: () => {} + }) + ) + ) + // The menu portals into document.body, not into host. + return [...document.querySelectorAll('[data-ctx-menu] button')].map( + (button) => button.lastElementChild?.textContent ?? '' + ) + } + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + if (original) Object.defineProperty(window, 'matchMedia', original) + else delete (window as { matchMedia?: unknown }).matchMedia + }) + + it('shows key hints and marks where there is a keyboard', async () => { + pointer(false) + expect(await sides()).toEqual(['Enter', '●', '2026-11-01']) + }) + + it('leaves the key hints out on a phone and keeps the marks', async () => { + pointer(true) + expect(await sides()).toEqual(['Open page', '●', '2026-11-01']) + }) +}) diff --git a/packages/app-core/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx index e7258c353..c2193bfc0 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -8,8 +8,12 @@ export interface ContextMenuItem { label?: string /** Optional SVG icon to the left of the label. */ icon?: JSX.Element - /** Right-aligned hint, e.g. a keyboard shortcut. */ + /** Right-aligned keyboard shortcut. A touch-first device (a phone) has no + * keyboard to press it on, so the menu leaves it out there. */ hint?: string + /** Right-aligned text that is not a key, shown on every device: the dot + * beside the current choice, a date, why an item is unavailable. */ + mark?: string /** Displayed in muted red (used for destructive actions). */ danger?: boolean disabled?: boolean @@ -82,6 +86,7 @@ export function ContextMenu({ x, y, items, title, findPlaceholder, anchorTop, on const [activeIdx, setActiveIdx] = useState(0) const [query, setQuery] = useState('') const vimMode = useStore((s) => s.vimMode) + const keyHints = !coarsePointer() // Build the filtered list. Each entry knows its original index so // reorder / filter doesn't lose the onSelect wiring. @@ -353,14 +358,14 @@ export function ContextMenu({ x, y, items, title, findPlaceholder, anchorTop, on {highlightMatch(item.label ?? '', query)} - {item.hint && ( + {(item.mark ?? (keyHints ? item.hint : undefined)) && ( - {item.hint} + {item.mark ?? item.hint} )} {item.checked && ( diff --git a/packages/app-core/src/components/DatabaseBoardView.tsx b/packages/app-core/src/components/DatabaseBoardView.tsx index 9e2c5411f..81450561c 100644 --- a/packages/app-core/src/components/DatabaseBoardView.tsx +++ b/packages/app-core/src/components/DatabaseBoardView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { DatabaseDoc, DbField, DbView } from '@shared/databases' import { EMPTY_GROUP } from '@shared/databases' import { boardColumns, viewRows } from '@shared/database-transforms' @@ -17,26 +17,43 @@ import { splitNoteLinks, isCheckboxTrue } from '../lib/database-cells' +import { + addBoardCard, + databaseBoardKeyAction, + resolveBoardCursor, + stepBoardCursor, + visibleBoardColumns, + type BoardCursor +} from '../lib/database-board' import { isImeComposing } from '../lib/ime' +import { useKeyboardCursor } from '../lib/keyboard-cursor' import { PlusIcon, ArrowUpRightIcon } from './icons' import { IconButton } from './ui/Button' +import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { DatabasePicksStrip } from './DatabasePicksStrip' interface Props { csvPath: string doc: DatabaseDoc view: DbView + isActive?: boolean } /** - * Board view: columns are the options of a `select` group-by field (+ an - * "(empty)" column). Dragging a card to a column sets that field on the row. - * Uses native HTML5 drag-and-drop. + * Board view: columns are the options of a `select` group-by field (+ a + * "No status" column while a card has none). Dragging a card to a column + * sets that field on the row. Uses native HTML5 drag-and-drop. The board + * also runs its own keyboard (lib/database-board.ts), so a Kanban made with + * New Kanban (#1038) is as keyboard-driven as the Table. */ -export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { +export function DatabaseBoardView({ csvPath, doc, view, isActive = true }: Props): JSX.Element { const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) const updateDatabaseSchema = useStore((s) => s.updateDatabaseSchema) + const deleteDatabaseRows = useStore((s) => s.deleteDatabaseRows) const openRecordPage = useStore((s) => s.openRecordPage) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) + const focusedPanel = useStore((s) => s.focusedPanel) + const vimMode = useStore((s) => s.vimMode) const selectFields = doc.fields.filter((f) => f.type === 'select') const groupField = @@ -55,10 +72,40 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { .map((id) => map.get(id)) .filter((f): f is DbField => !!f && !f.hidden && f.id !== doc.idFieldId && f.id !== titleField?.id && f.id !== groupField?.id) + const boardRef = useRef(null) + const cursorRing = useKeyboardCursor() ? 'group-focus/board:ring-1 group-focus/board:ring-accent/60' : '' const [dragRow, setDragRow] = useState(null) const [overCol, setOverCol] = useState(null) const [addingOption, setAddingOption] = useState(false) const [optionDraft, setOptionDraft] = useState('') + const [cursor, setCursor] = useState(null) + // The column whose Add card field is open, and what is typed in it. + const [addingCardIn, setAddingCardIn] = useState(null) + const [cardDraft, setCardDraft] = useState('') + // Which column's field is open, read by its blur: Escape closes the field + // before the unmount's blur can land, so that blur never adds the card. + const addCardOpen = useRef(null) + const [cardMenu, setCardMenu] = useState<{ x: number; y: number; rowId: string } | null>(null) + + // The board takes the keyboard whenever its pane's content does, as the + // Table grid does (DatabaseTableView's claimFocus, which explains the + // cases): never while another panel owns the keys or a control inside the + // view has focus. + useEffect(() => { + if (!isActive || (focusedPanel !== 'editor' && focusedPanel !== null)) return + const claimFocus = (): void => { + const a = document.activeElement as HTMLElement | null + if (a && a !== document.body && a.closest('input, textarea, select, button, [contenteditable="true"]')) + return + boardRef.current?.focus({ preventScroll: true }) + } + const raf = requestAnimationFrame(claimFocus) + window.addEventListener('focus', claimFocus) + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('focus', claimFocus) + } + }, [isActive, csvPath, focusedPanel, view.id]) if (!groupField) { return ( @@ -70,16 +117,130 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { } const optionOrder = view.boardColumnOrder ?? groupField.options?.map((o) => o.value) ?? [] - const columns = boardColumns(visibleRows, groupField, optionOrder) + const columns = visibleBoardColumns(boardColumns(visibleRows, groupField, optionOrder)) + const at = resolveBoardCursor(columns, cursor) + const focusedRowId = at.column >= 0 ? (columns[at.column].rows[at.card]?.id ?? null) : null + + // Rapid keys can land before the store's optimistic write re-renders this + // view, so writes start from the store's doc rather than the prop. + const latestDoc = (): DatabaseDoc => useStore.getState().databases[csvPath] ?? doc const moveTo = (rowId: string, columnKey: string): void => { const value = columnKey === EMPTY_GROUP ? '' : columnKey - updateDatabaseRows(csvPath, setCell(doc, rowId, groupField.id, value)) + updateDatabaseRows(csvPath, setCell(latestDoc(), rowId, groupField.id, value)) } const columnLabel = (key: string): string => key === EMPTY_GROUP ? 'No ' + groupField.name.toLowerCase() : optionLabel(groupField, key) + // Back to the board after its own field or menu closes, unless that + // closed it into something else (Open page leaves for the record page). + const focusBoard = (): void => { + requestAnimationFrame(() => { + const board = boardRef.current + const a = document.activeElement + if (!board?.isConnected || (a && a !== document.body && !board.contains(a))) return + board.focus({ preventScroll: true }) + }) + } + + const startAddingCard = (columnKey: string): void => { + setCardDraft('') + setAddingCardIn(columnKey) + addCardOpen.current = columnKey + setCursor({ columnKey, rowId: null }) + } + + /** Add the typed card to `columnKey`; true when one was added. */ + const commitCard = (columnKey: string): boolean => { + const title = cardDraft.trim() + if (!title) return false + const added = addBoardCard(latestDoc(), view.id, titleField?.id, groupField.id, columnKey, title) + if (added.picksChanged) updateDatabaseSchema(csvPath, added.doc) + else updateDatabaseRows(csvPath, added.doc) + setCardDraft('') + setCursor({ columnKey, rowId: added.rowId }) + return true + } + + const closeAddCard = (): void => { + addCardOpen.current = null + setAddingCardIn(null) + setCardDraft('') + } + + const openCardMenu = (rowId: string): void => { + const el = boardRef.current?.querySelector(`[data-db-board-card="${CSS.escape(rowId)}"]`) + const rect = el?.getBoundingClientRect() + setCardMenu({ x: rect ? rect.left + 12 : 0, y: rect ? rect.bottom : 0, rowId }) + } + + const cardMenuItems = (rowId: string): ContextMenuItem[] => { + const row = doc.rows.find((r) => r.id === rowId) + const here = columns.find((c) => c.rows.some((r) => r.id === rowId))?.key + return [ + { + label: 'Open page', + icon: , + hint: vimMode ? 'o' : 'Enter', + onSelect: () => void openRecordPage(csvPath, rowId) + }, + { kind: 'separator' }, + ...columns + .filter((c) => c.key !== here) + .map((c): ContextMenuItem => ({ + label: `Move to ${columnLabel(c.key)}`, + onSelect: () => { + moveTo(rowId, c.key) + setCursor({ columnKey: c.key, rowId }) + } + })), + { kind: 'separator' }, + { + label: 'Delete card…', + danger: true, + disabled: !row, + onSelect: () => void deleteDatabaseRows(csvPath, [rowId], { confirm: true }).then(focusBoard) + } + ] + } + + const onBoardKeyDown = (e: ReactKeyboardEvent): void => { + // Only the board itself: a key typed in the Add card field, the column + // field, or the open card menu (a portal, whose events still bubble + // here through React) belongs to that control. + if (e.target !== e.currentTarget) return + const action = databaseBoardKeyAction(e, useStore.getState().vimMode) + if (!action) return + e.preventDefault() + switch (action) { + case 'left': + case 'right': + case 'up': + case 'down': + setCursor(stepBoardCursor(columns, cursor, action)) + return + case 'move-left': + case 'move-right': { + const target = columns[at.column + (action === 'move-left' ? -1 : 1)] + if (!focusedRowId || !target) return + moveTo(focusedRowId, target.key) + setCursor({ columnKey: target.key, rowId: focusedRowId }) + return + } + case 'open': + if (focusedRowId) void openRecordPage(csvPath, focusedRowId) + else if (columns[at.column]) startAddingCard(columns[at.column].key) + return + case 'add': + if (columns[at.column]) startAddingCard(columns[at.column].key) + return + case 'menu': + if (focusedRowId) openCardMenu(focusedRowId) + return + } + } + return (
{viewHasPicks(view) && ( @@ -107,17 +268,38 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element {
-
- {columns.map((col) => ( +
{ + if (useStore.getState().focusedPanel !== 'editor') setFocusedPanel('editor') + }} + // The cursor ring follows the board's own :focus. A focus event is + // not a reliable signal: the board can take focus while the window + // is in the background, and the ring then never showed. + className="group/board flex min-h-0 flex-1 gap-3 overflow-x-auto px-4 pb-4 outline-none focus:outline-none" + > + {columns.map((col, colIdx) => { + const columnFocused = colIdx === at.column + return (
{ e.preventDefault() setOverCol(col.key) }} onDragLeave={() => setOverCol((c) => (c === col.key ? null : c))} onDrop={() => { - if (dragRow) moveTo(dragRow, col.key) + if (dragRow) { + moveTo(dragRow, col.key) + setCursor({ columnKey: col.key, rowId: dragRow }) + } setDragRow(null) setOverCol(null) }} @@ -133,20 +315,29 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { {col.rows.length}
- {col.rows.length === 0 ? ( -
- empty -
- ) : ( - col.rows.map((row) => ( + {col.rows.map((row) => { + const cardFocused = columnFocused && row.id === focusedRowId + return (
setDragRow(row.id)} onDragEnd={() => setDragRow(null)} + onMouseDown={() => { + setCursor({ columnKey: col.key, rowId: row.id }) + focusBoard() + }} + onDoubleClick={() => void openRecordPage(csvPath, row.id)} + onContextMenu={(e) => { + e.preventDefault() + setCursor({ columnKey: col.key, rowId: row.id }) + setCardMenu({ x: e.clientX, y: e.clientY, rowId: row.id }) + }} className={[ 'group/card cursor-grab rounded-md border border-paper-300/60 bg-paper-100/85 px-2.5 py-1.5 active:cursor-grabbing', - dragRow === row.id ? 'opacity-50' : 'hover:bg-paper-200/60' + dragRow === row.id ? 'opacity-50' : 'hover:bg-paper-200/60', + cardFocused ? cursorRing : '' ].join(' ')} >
@@ -188,11 +379,54 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element {
)}
- )) + ) + })} + + {addingCardIn === col.key ? ( + setCardDraft(e.target.value)} + onBlur={() => { + if (addCardOpen.current !== col.key) return + commitCard(col.key) + closeAddCard() + }} + onKeyDown={(e) => { + e.stopPropagation() + // Enter adds the card and keeps the field open for the next. + if (e.key === 'Enter' && !isImeComposing(e)) { + e.preventDefault() + commitCard(col.key) + } else if (e.key === 'Escape') { + e.preventDefault() + closeAddCard() + focusBoard() + } + }} + className="w-full rounded-md border border-paper-300 bg-paper-50 px-2 py-1.5 text-sm text-ink-900 outline-none focus:border-accent" + /> + ) : ( + )}
- ))} + ) + })} {/* Add a new option (= a new column). */}
@@ -229,6 +463,18 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { )}
+ + {cardMenu && ( + { + setCardMenu(null) + focusBoard() + }} + /> + )} ) } diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index dd87d53e0..8dbacacae 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -10,6 +10,7 @@ import { import { createPortal } from 'react-dom' import type { DatabaseDoc, DbField, DbRow, DbView, FieldType, SelectOptionsSource } from '@shared/databases' import { viewRows } from '@shared/database-transforms' +import { useKeyboardCursor } from '../lib/keyboard-cursor' import { resolveTemplateFolder } from '@shared/template-folder' import { useStore } from '../store' import { tagSourceSuggestions } from '../lib/link-candidates' @@ -78,6 +79,7 @@ interface Props { export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns }: Props): JSX.Element { const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) + const keyCursor = useKeyboardCursor() const updateDatabaseSchema = useStore((s) => s.updateDatabaseSchema) const deleteDatabaseRows = useStore((s) => s.deleteDatabaseRows) const openRecordPage = useStore((s) => s.openRecordPage) @@ -577,7 +579,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns { label: 'Rename field', onSelect: () => setRenamingField(field.id) }, ...(Object.keys(FIELD_TYPE_LABELS) as FieldType[]).map((t) => ({ label: `Type: ${FIELD_TYPE_LABELS[t]}`, - hint: field.type === t ? '●' : undefined, + mark: field.type === t ? '●' : undefined, onSelect: () => updateDatabaseSchema(csvPath, retypeField(doc, field.id, t)) })), // Where a select field's popover discovers options beyond the hand-added @@ -587,13 +589,13 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns { kind: 'separator' }, { label: 'Options: manual only', - hint: !field.optionsSource ? '●' : undefined, + mark: !field.optionsSource ? '●' : undefined, onSelect: () => updateDatabaseSchema(csvPath, setFieldOptionsSource(doc, field.id, null)) }, { label: 'Options: all notes', - hint: field.optionsSource?.kind === 'notes' ? '●' : undefined, + mark: field.optionsSource?.kind === 'notes' ? '●' : undefined, onSelect: () => updateDatabaseSchema( csvPath, @@ -602,12 +604,12 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns }, { label: 'Options: notes in folder…', - hint: field.optionsSource?.kind === 'folder' ? '●' : undefined, + mark: field.optionsSource?.kind === 'folder' ? '●' : undefined, onSelect: () => setSourcePrompt({ fieldId: field.id, kind: 'folder' }) }, { label: 'Options: notes with tag…', - hint: field.optionsSource?.kind === 'tag' ? '●' : undefined, + mark: field.optionsSource?.kind === 'tag' ? '●' : undefined, onSelect: () => setSourcePrompt({ fieldId: field.id, kind: 'tag' }) } ] as ContextMenuItem[]) @@ -752,7 +754,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns }} className={[ 'group/h min-w-32 max-w-[420px] border-r border-paper-300/40 px-2.5 py-2 text-left text-2xs font-medium uppercase tracking-wide text-ink-500', - active.row < 0 && active.col === colIndex ? 'ring-2 ring-inset ring-accent' : '', + keyCursor && active.row < 0 && active.col === colIndex ? 'ring-2 ring-inset ring-accent' : '', // Drop indicator: the dragged column lands *before* this one. dragOverFieldId === field.id ? 'border-l-2 border-l-accent bg-accent/5' : '' ].join(' ')} @@ -837,7 +839,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns // #335: cap the column so a long Text value truncates instead // of stretching the column to fill the table. 'relative max-w-[420px] border-r border-paper-300/40 p-0 align-top', - isActive ? 'ring-2 ring-inset ring-accent' : '' + isActive && keyCursor ? 'ring-2 ring-inset ring-accent' : '' ].join(' ') // Keep keyboard focus on the grid container (not the inner // button) so vim motions keep working; sync active to the click. diff --git a/packages/app-core/src/components/DatabaseView.tsx b/packages/app-core/src/components/DatabaseView.tsx index da98c5dc3..f1af6e9a4 100644 --- a/packages/app-core/src/components/DatabaseView.tsx +++ b/packages/app-core/src/components/DatabaseView.tsx @@ -178,7 +178,9 @@ export function DatabaseView({ return (
-
+ {/* Wraps like GalleryView's header: in a narrow pane the tools drop to a + second line whole, rather than squeezing the title to nothing. */} +
{editingTitle && canRenameTitle ? ( openColumnsFor(activeView.id)} /> ) : ( - + )}
diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 087cd38fa..ff0545979 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -31,6 +31,7 @@ import { moveLineDown, moveLineUp } from "@codemirror/commands"; import { unfoldAll } from "@codemirror/language"; import { foldAllOutline, foldAtCursor, unfoldAtCursor } from "../lib/cm-heading-fold"; import { isTagsViewActive, isTasksViewActive, useStore } from "../store"; +import { remindTaskAtCursor, repeatTaskAtCursor } from "../lib/editor-task-recurrence"; import { buildCommands, type Command } from "../lib/commands"; import { rankItems } from "../lib/fuzzy-score"; import { BUILTIN_TEMPLATES } from "@shared/builtin-templates"; @@ -110,6 +111,7 @@ import { copyLinkAtCursor } from "../lib/link-copy"; import { followLinkTarget } from "../lib/follow-link"; import { openLinkPopoverAtCursor } from "../lib/link-popover-open"; import { openGalleryFromArgument } from "../lib/folder-gallery-open"; +import { newKanbanFromArgument } from "../lib/kanban-create"; let vimCommandsRegistered = false; let syncedVimBindings: Partial> = {}; @@ -554,6 +556,25 @@ function registerVimCommands(): void { Vim.defineEx("format", "format", () => { void useStore.getState().formatActiveNote(); }); + // `:repeat weekly` / `:remind 9am` set the recurring-task fields on the task + // under the cursor (#1036); `off` takes one away, and bare they open the + // same picker as `r` / `R` in the Tasks views. + Vim.defineEx( + "repeat", + "repeat", + (_cm: unknown, params: { argString?: string } | undefined) => { + const view = useStore.getState().editorViewRef; + if (view) void repeatTaskAtCursor(view, params?.argString ?? ""); + }, + ); + Vim.defineEx( + "remind", + "remind", + (_cm: unknown, params: { argString?: string } | undefined) => { + const view = useStore.getState().editorViewRef; + if (view) void remindTaskAtCursor(view, params?.argString ?? ""); + }, + ); // `:cover` (`:cov`) is the ex twin of Space l c. Bare, it opens the picker; // `:cover!` or `:cover remove` takes the cover off; `paste` uses the // clipboard image; `reposition` enters reposition mode and `40%` sets the @@ -751,6 +772,15 @@ function registerVimCommands(): void { void openGalleryFromArgument(params?.argString ?? ""); }, ); + // `:kanban` creates a Kanban board (#1038) where `Space k` would; `:kanban + // Biology` puts it in that folder. + Vim.defineEx( + "kanban", + "kanb", + (_cm: unknown, params: { argString?: string } | undefined) => { + void newKanbanFromArgument(params?.argString ?? ""); + }, + ); // Quick-add a whole-note task file. `:newtask` (or `:task`) prompts for a // title and creates it at the configured tasks location; `:newtask ` diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 7eeae33f5..d5300e28a 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -67,6 +67,9 @@ import { forwardOnCheckboxArrow } from '../lib/cm-forward-task' import { markerHopCommands } from '../lib/cm-marker-hop' import { isInMarkdownCode } from '../lib/cm-auto-pairs' import { toggleCheckbox } from '../lib/cm-toggle-checkbox' +import { editorRecurrenceContext } from '../lib/cm-task-recurrence' +import { taskFieldSource } from '../lib/cm-task-field-complete' +import { dailyDueForNote } from '../lib/task-check-off' import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { isVimAwaitingArgument } from '../lib/vim-nav' @@ -1483,7 +1486,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } items.push({ label: pick.label, - hint: pick.hint, + mark: pick.hint, keywords: pick.keywords, checked: pick.checked, onSelect: () => { @@ -2395,6 +2398,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { editorImagePaste.of((files, view) => { void importPastedImagesRef.current?.(files, view) }), + editorRecurrenceContext.of(() => ({ dailyDue: dailyDueForNote(viewPathRef.current) })), commentDecorationField, agentReviewCompartment.of([]), noteCoverCompartment.of(noteCoverEditorExtension(s0.noteCoverPlacement, noteCoverSlotSink)), @@ -2433,6 +2437,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { override: [ slashCommandSource, calloutTypeSource, + taskFieldSource, dateShortcutSource, latexCommandSource, typstCommandSource, @@ -4845,7 +4850,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) : activeTab && isAssetTabPath(activeTab) ? ( isDatabaseCsvPath(assetPathFromTab(activeTab) ?? '') ? ( + // Keyed like GalleryView: another database opened in this pane + // starts fresh instead of inheriting this one's scroll, raw-CSV + // mode and open menus. @@ -4855,7 +4864,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) : activeTab && isDiagramTabPath(activeTab) ? ( ) : activeTab && isDatabaseTabPath(activeTab) ? ( - + ) : activeTab && isExcalidrawPath(activeTab) ? ( ) : activeTab && @@ -5118,7 +5127,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { (entry): ContextMenuItem => ({ label: entry.label, disabled: entry.disabled, - hint: entry.reason ?? (entry.action === 'remove' ? undefined : calloutChordHint), + mark: entry.reason, + hint: entry.action === 'remove' ? undefined : calloutChordHint, onSelect: () => { const view = viewRef.current if (!view) return diff --git a/packages/app-core/src/components/FolderPickerInput.test.ts b/packages/app-core/src/components/FolderPickerInput.test.ts new file mode 100644 index 000000000..909d03b22 --- /dev/null +++ b/packages/app-core/src/components/FolderPickerInput.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment jsdom + +import { act, createElement, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderChoice } from '../lib/folder-picker' +import { FolderPickerInput } from './FolderPickerInput' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const choices: FolderChoice[] = ['Books', 'Projects', 'Projects/Alpha', 'Journal'].map((value) => ({ + value, + label: value +})) + +describe('FolderPickerInput (#1033)', () => { + let host: HTMLDivElement + let root: Root + let originalScrollIntoView: PropertyDescriptor | undefined + const onPick = vi.fn() + const onKeyDown = vi.fn() + const windowKeys = vi.fn() + + function Harness(): JSX.Element { + const [text, setText] = useState('') + return createElement(FolderPickerInput, { + value: text, + onChange: setText, + choices, + current: 'Projects', + onPick: (value: string) => { + setText(value) + onPick(value) + }, + onKeyDown, + 'aria-label': 'Folder' + }) + } + + beforeEach(() => { + onPick.mockReset() + onKeyDown.mockReset() + windowKeys.mockReset() + window.addEventListener('keydown', windowKeys) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + act(() => root.render(createElement(Harness))) + }) + + afterEach(() => { + act(() => root.unmount()) + window.removeEventListener('keydown', windowKeys) + document.body.innerHTML = '' + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } else { + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + } + }) + + const field = (): HTMLInputElement => host.querySelector('input')! + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + const type = (text: string): void => { + act(() => { + setValue.call(field(), text) + field().dispatchEvent(new Event('input', { bubbles: true })) + }) + } + const press = (key: string, init: KeyboardEventInit = {}): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }) + act(() => { + field().dispatchEvent(event) + }) + return event + } + const rows = (): string[] => + [...document.querySelectorAll('[data-folder-picker-option]')].map( + (row) => row.dataset.folderPickerOption ?? '' + ) + const highlighted = (): string | null => + document.querySelector('[data-folder-picker-option][aria-selected="true"]')?.dataset + .folderPickerOption ?? null + + it('lists every folder on focus, ticks the saved one, and highlights none', () => { + expect(rows()).toEqual([]) + act(() => field().focus()) + expect(rows()).toEqual(['Books', 'Projects', 'Projects/Alpha', 'Journal']) + expect(highlighted()).toBeNull() + expect(field().getAttribute('aria-expanded')).toBe('true') + const ticked = document.querySelector('[data-folder-picker-option="Projects"] svg') + expect(ticked).toBeTruthy() + }) + + it('moves with the arrows and with Ctrl+N / Ctrl+P, and letters stay text', () => { + act(() => field().focus()) + press('ArrowDown') + expect(highlighted()).toBe('Books') + press('n', { ctrlKey: true }) + expect(highlighted()).toBe('Projects') + press('p', { ctrlKey: true }) + expect(highlighted()).toBe('Books') + press('ArrowUp') + expect(highlighted()).toBeNull() + + // A bare j is not a motion: it reaches the caller like any other key, + // and typing it narrows the list as text. + const j = press('j') + expect(j.defaultPrevented).toBe(false) + expect(onKeyDown).toHaveBeenCalledTimes(1) + type('j') + expect(rows()).toEqual(['Journal', 'Projects', 'Projects/Alpha']) + }) + + it('typing narrows and highlights the first match, which Enter picks', () => { + act(() => field().focus()) + type('proj') + expect(rows()).toEqual(['Projects', 'Projects/Alpha']) + expect(highlighted()).toBe('Projects') + press('ArrowDown') + press('Enter') + expect(onPick).toHaveBeenCalledWith('Projects/Alpha') + expect(field().value).toBe('Projects/Alpha') + expect(rows()).toEqual([]) + expect(onKeyDown).not.toHaveBeenCalled() + }) + + it('hands Enter on a typed path with no row to the caller', () => { + act(() => field().focus()) + type('New/Folder') + expect(rows()).toEqual([]) + press('Enter') + expect(onPick).not.toHaveBeenCalled() + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: 'Enter' })) + }) + + it('Escape closes the list without reaching the window; the next one is the caller’s', () => { + act(() => field().focus()) + press('Escape') + expect(rows()).toEqual([]) + expect(windowKeys).not.toHaveBeenCalled() + expect(onKeyDown).not.toHaveBeenCalled() + press('Escape') + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: 'Escape' })) + expect(windowKeys).toHaveBeenCalledTimes(1) + }) + + it('a click on a row picks it and keeps focus in the field', () => { + act(() => field().focus()) + const row = document.querySelector('[data-folder-picker-option="Journal"]')! + const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + row.dispatchEvent(down) + expect(down.defaultPrevented).toBe(true) + act(() => row.click()) + expect(onPick).toHaveBeenCalledWith('Journal') + expect(document.activeElement).toBe(field()) + }) + + it('a window that loses focus and gets it back reopens the narrowed list', () => { + act(() => field().focus()) + type('proj') + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) + act(() => field().blur()) + expect(rows()).toEqual([]) + hasFocus.mockReturnValue(true) + act(() => field().focus()) + expect(rows()).toEqual(['Projects', 'Projects/Alpha']) + + // Landing in the field anew, the window focused all along, lists + // everything again. + act(() => field().blur()) + act(() => field().focus()) + expect(rows()).toEqual(['Books', 'Projects', 'Projects/Alpha', 'Journal']) + hasFocus.mockRestore() + }) + + it('leaves every key to an IME composition', () => { + act(() => field().focus()) + const enter = press('Enter', { isComposing: true }) + expect(enter.defaultPrevented).toBe(false) + const down = press('ArrowDown', { isComposing: true }) + expect(down.defaultPrevented).toBe(false) + expect(highlighted()).toBeNull() + }) +}) diff --git a/packages/app-core/src/components/FolderPickerInput.tsx b/packages/app-core/src/components/FolderPickerInput.tsx new file mode 100644 index 000000000..89d2e1663 --- /dev/null +++ b/packages/app-core/src/components/FolderPickerInput.tsx @@ -0,0 +1,218 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import type { + FocusEvent as ReactFocusEvent, + InputHTMLAttributes, + KeyboardEvent as ReactKeyboardEvent +} from 'react' +import { createPortal } from 'react-dom' +import { dropdownRectForElement, type DropdownRect } from '../lib/dropdown-placement' +import type { FolderChoice } from '../lib/folder-picker' +import { isImeComposing } from '../lib/ime' +import { useFolderPicker } from '../lib/use-folder-picker' +import { CheckIcon } from './icons' + +/** Settings lists every folder, scrolling, rather than the New note form's + * short list; this only bounds a vault with thousands of them. */ +const SETTINGS_FOLDER_ROWS = 200 +const ROW_HEIGHT = 32 + +type PassThroughProps = Omit< + InputHTMLAttributes, + 'value' | 'onChange' | 'onFocus' | 'onBlur' | 'onKeyDown' | 'role' | 'list' +> + +/** + * A text field for a vault folder (#1033). Focusing it lists the vault's + * folders under it, typing narrows the list and highlights the first match, + * and a typed path that matches nothing is still the field's value. The keys + * are the palettes': the arrows, Ctrl+N / Ctrl+P (Ctrl+J / Ctrl+K), Enter to + * pick, Escape to close the list. Letters are only ever text. + * + * The field stays the caller's: it owns the text and decides what a pick, an + * Enter on typed text, a blur or an Escape with the list closed do, so each + * Settings row keeps its own validation and save rules. + */ +export function FolderPickerInput({ + value, + onChange, + choices, + onPick, + onKeyDown, + onFocus, + onBlur, + current, + 'aria-label': ariaLabel, + ...inputProps +}: PassThroughProps & { + value: string + onChange: (text: string) => void + choices: readonly FolderChoice[] + /** A row was picked, by Enter or a click. The list has closed. */ + onPick: (value: string) => void + /** Every key the picker does not take: Enter with no row highlighted, + * Escape with the list closed, and everything else. */ + onKeyDown?: (event: ReactKeyboardEvent) => void + onFocus?: () => void + onBlur?: (event: ReactFocusEvent) => void + /** The value saved now, ticked in the list. */ + current?: string | null +}): JSX.Element { + const inputRef = useRef(null) + const listRef = useRef(null) + const listId = useId() + const [open, setOpen] = useState(false) + // The field also blurs when the window loses focus, and takes focus back + // with it. Coming back is not landing in the field anew: the list reopens + // as it was, narrowed to the typed text, instead of listing everything. + const resumeOnFocus = useRef(false) + const [rect, setRect] = useState(null) + const picker = useFolderPicker(choices, value, SETTINGS_FOLDER_ROWS) + const shown = open && picker.rows.length > 0 + const rowCount = picker.rows.length + + useLayoutEffect(() => { + if (!shown) return + const update = (): void => { + const el = inputRef.current + if (el) setRect(dropdownRectForElement(el, { estHeight: rowCount * ROW_HEIGHT + 8 })) + } + update() + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [shown, rowCount]) + + useEffect(() => { + if (!shown || picker.active < 0) return + listRef.current + ?.querySelector(`[data-folder-picker-idx="${picker.active}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [shown, picker.active]) + + const pick = (choice: FolderChoice): void => { + setOpen(false) + picker.reset() + onPick(choice.value) + } + + const handleKeyDown = (e: ReactKeyboardEvent): void => { + if (isImeComposing(e)) { + onKeyDown?.(e) + return + } + // A closed list (after a pick or Escape) opens again on the keys that + // move through it, the way a combo box does. + if (picker.step(e)) { + setOpen(true) + return + } + const plainEnter = + e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey + if (plainEnter && shown && picker.highlighted) { + e.preventDefault() + pick(picker.highlighted) + return + } + if (e.key === 'Escape' && shown) { + // Close the list only; Escape reaching Settings would close it too. + // Nothing stays highlighted behind a closed list, so opening it again + // starts from the typed value. + e.preventDefault() + e.stopPropagation() + setOpen(false) + picker.setActive(-1) + return + } + onKeyDown?.(e) + } + + return ( + <> + { + onChange(e.target.value) + picker.typed(e.target.value) + setOpen(true) + }} + onFocus={() => { + if (!resumeOnFocus.current) picker.reset() + resumeOnFocus.current = false + setOpen(true) + onFocus?.() + }} + onBlur={(e) => { + resumeOnFocus.current = !document.hasFocus() + setOpen(false) + onBlur?.(e) + }} + onKeyDown={handleKeyDown} + /> + {shown && + rect && + createPortal( +
e.preventDefault()} + > +
+ {picker.rows.map((row, i) => ( + + ))} +
+
, + document.body + )} + + ) +} diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 116ae9326..885e88ccb 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -105,6 +105,7 @@ function resolveShortcutKeys( if (action === 'Toggle timeline') return leaderShortcut(overrides, 'vim.leaderTimeline') if (action === 'Unresolved links') return leaderShortcut(overrides, 'vim.leaderUnresolvedLinks') if (action === 'Folder gallery') return leaderShortcut(overrides, 'vim.leaderFolderGallery') + if (action === 'New Kanban') return leaderShortcut(overrides, 'vim.leaderNewKanban') if (action === 'Callout type') { return chord( leaderShortcut(overrides, 'vim.leaderNoteActions'), diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 6f0ddac18..5c1968f4c 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -31,6 +31,7 @@ import { listDatabaseLinkTargets, resolveDatabaseWikilink } from "../lib/databas import { externalLinkUrl, resolveInternalNoteHref } from "../lib/internal-links"; import { copyableLink, linkMenuItems, type CopyableLink } from "../lib/link-copy"; import { toggleTaskAtIndex } from "../lib/tasklists"; +import { checkOffTaskInMarkdown } from "../lib/task-check-off"; import { enhanceLocalAssetNodes, hrefFragment, @@ -449,10 +450,10 @@ export const Preview = memo(function Preview({ 10, ); if (!Number.isFinite(taskIndex) || taskIndex < 0) return; - const nextMarkdown = toggleTaskAtIndex( + const nextMarkdown = checkOffTaskInMarkdown( markdownRef.current, taskIndex, - true, + notePathRef.current, ); if (nextMarkdown === markdownRef.current) return; updateActiveBodyRef.current(nextMarkdown); @@ -682,11 +683,10 @@ export const Preview = memo(function Preview({ if (!input || input.type !== "checkbox") return; const taskIndex = Number.parseInt(input.dataset.taskIndex ?? "-1", 10); if (!Number.isFinite(taskIndex) || taskIndex < 0) return; - const nextMarkdown = toggleTaskAtIndex( - markdownRef.current, - taskIndex, - input.checked, - ); + // Checking off a recurring task writes its next occurrence (#1036). + const nextMarkdown = input.checked + ? checkOffTaskInMarkdown(markdownRef.current, taskIndex, notePathRef.current) + : toggleTaskAtIndex(markdownRef.current, taskIndex, false); if (nextMarkdown === markdownRef.current) return; updateActiveBodyRef.current(nextMarkdown); void persistActiveRef.current(); diff --git a/packages/app-core/src/components/SearchCreateForm.tsx b/packages/app-core/src/components/SearchCreateForm.tsx index 54a4eb410..7bdfa61f7 100644 --- a/packages/app-core/src/components/SearchCreateForm.tsx +++ b/packages/app-core/src/components/SearchCreateForm.tsx @@ -4,6 +4,8 @@ import type { NoteMeta } from '@shared/ipc' import { useStore } from '../store' import { isImeComposing } from '../lib/ime' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { stepPickerIndex } from '../lib/folder-picker' +import { useFolderPicker } from '../lib/use-folder-picker' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPrimaryNotesAtRoot, noteFolderSubpath } from '../lib/vault-layout' import { resolveTypstPreambleFolder } from '../lib/typst-preamble' @@ -14,7 +16,6 @@ import { buildDestinationChoices, checkNoteName, destinationLabel, - filterDestinationChoices, findNameCollision, normalizeTag, parseDestinationText, @@ -75,12 +76,7 @@ export function SearchCreateForm({ const [tagText, setTagText] = useState('') const [focused, setFocused] = useState(null) // -1 keeps the typed value; 0..n highlights a row, which Enter then picks. - const [folderActive, setFolderActive] = useState(-1) const [tagActive, setTagActive] = useState(-1) - // The Folder field can arrive prefilled from a typed path. Landing in it - // lists every folder (the user came to look, or to change it); the list - // narrows only once they type. - const [folderTyped, setFolderTyped] = useState(false) const nameRef = useRef(null) const tagsRef = useRef(null) const listRef = useRef(null) @@ -126,11 +122,11 @@ export function SearchCreateForm({ const error = nameCheck.error ?? destination.error ?? typedTagError const canCreate = !error && !collision?.sameFolder - const folderRows = useMemo( - () => - focused === 'folder' ? filterDestinationChoices(choices, folderTyped ? folderText : '') : [], - [choices, focused, folderText, folderTyped] - ) + // The Folder field can arrive prefilled from a typed path. Landing in it + // lists every folder (the user came to look, or to change it); the list + // narrows only once they type. + const folderPicker = useFolderPicker(choices, folderText) + const folderRows = focused === 'folder' ? folderPicker.rows : [] const tagRows = useMemo(() => { if (focused !== 'tags') return [] const rows: TagRow[] = rankTagChoices(tagText, tagCounts, tags).map((entry) => ({ @@ -144,7 +140,7 @@ export function SearchCreateForm({ return rows }, [focused, tagCounts, tagText, tags]) - const activeRow = focused === 'folder' ? folderActive : focused === 'tags' ? tagActive : -1 + const activeRow = focused === 'folder' ? folderPicker.active : focused === 'tags' ? tagActive : -1 useEffect(() => { if (activeRow < 0) return listRef.current @@ -197,7 +193,7 @@ export function SearchCreateForm({ const pickFolder = (value: string): void => { setFolderText(value) - setFolderActive(-1) + folderPicker.setActive(-1) tagsRef.current?.focus() } @@ -209,21 +205,6 @@ export function SearchCreateForm({ const pickTagRow = (row: TagRow): void => commitTag(row.tag) - const move = ( - rows: number, - setActive: (update: (prev: number) => number) => void, - delta: number - ): void => { - if (rows === 0) return - // Cycle through [typed value (-1), row 0 … n-1] and wrap around. - setActive((prev) => { - const next = prev + delta - if (next < -1) return rows - 1 - if (next >= rows) return -1 - return next - }) - } - // Escape and the modified Enters belong to the whole form, whichever field // has focus. Stopping propagation keeps the window-level Escape handler // from closing the palette: here Escape means "back to the results". @@ -246,15 +227,10 @@ export function SearchCreateForm({ const onFolderKeyDown = (e: ReactKeyboardEvent): void => { if (isImeComposing(e)) return - if (isPaletteNextKey(e)) { - e.preventDefault() - move(folderRows.length, setFolderActive, 1) - } else if (isPalettePreviousKey(e)) { - e.preventDefault() - move(folderRows.length, setFolderActive, -1) - } else if (isPlainEnter(e)) { + if (folderPicker.step(e)) return + if (isPlainEnter(e)) { e.preventDefault() - const row = folderActive >= 0 ? folderRows[folderActive] : undefined + const row = folderPicker.highlighted if (row) pickFolder(row.value) else create() } @@ -264,10 +240,10 @@ export function SearchCreateForm({ if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() - move(tagRows.length, setTagActive, 1) + setTagActive((current) => stepPickerIndex(current, 1, tagRows.length)) } else if (isPalettePreviousKey(e)) { e.preventDefault() - move(tagRows.length, setTagActive, -1) + setTagActive((current) => stepPickerIndex(current, -1, tagRows.length)) } else if (isPlainEnter(e)) { e.preventDefault() const row = tagActive >= 0 ? tagRows[tagActive] : undefined @@ -331,16 +307,11 @@ export function SearchCreateForm({ value={folderText} onChange={(e) => { setFolderText(e.target.value) - setFolderTyped(true) - // Preselect the first match while typing, so Enter picks it - // without an arrow key first; the typed value stays one - // ArrowUp away, for a folder that does not exist yet. (#467) - setFolderActive(e.target.value.trim() ? 0 : -1) + folderPicker.typed(e.target.value) }} onFocus={() => { setFocused('folder') - setFolderActive(-1) - setFolderTyped(false) + folderPicker.reset() }} onBlur={() => setFocused((f) => (f === 'folder' ? null : f))} onKeyDown={onFolderKeyDown} @@ -470,8 +441,8 @@ export function SearchCreateForm({ data-search-form-idx={i} data-search-form-folder={row.value} onClick={() => pickFolder(row.value)} - onMouseMove={() => setFolderActive(i)} - className={[ROW_CLASS, i === folderActive ? 'bg-paper-200' : 'hover:bg-paper-200/70'].join( + onMouseMove={() => folderPicker.setActive(i)} + className={[ROW_CLASS, i === folderPicker.active ? 'bg-paper-200' : 'hover:bg-paper-200/70'].join( ' ' )} > diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 2353562a9..88a4641da 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -46,6 +46,7 @@ const mocks = vi.hoisted(() => { ], folderTemplates: [], setTemplateFolder: vi.fn(), + toggleTasksIncludedFolder: vi.fn(), externalApplicationSchemes: [], setExternalApplicationSchemes: vi.fn(), darkSidebar: false, @@ -407,6 +408,34 @@ describe("SettingsModal date note directories", () => { expect(await sharesRowFound()).toBe(true); }); + it("shows the reminder settings only where the host fires reminders", async () => { + const openTasksPage = async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "reminder")); + const found = [...host.querySelectorAll("aside nav button")].some( + (button) => button.textContent?.includes("Task reminders"), + ); + await act(async () => changeInput(search, "hide empty tasks")); + const emptyTasks = [...host.querySelectorAll("aside nav button")].find( + (button) => button.textContent?.includes("Hide empty tasks"), + ); + await act(async () => emptyTasks!.click()); + return { + found, + rows: host.querySelectorAll( + '[data-settings-search-id="task-reminders"], [data-settings-search-id="task-reminder-time"]', + ).length, + }; + }; + // A phone or the web app: no schedule to hand reminders to. + expect(await openTasksPage()).toEqual({ found: false, rows: 0 }); + act(() => root.unmount()); + root = createRoot(host); + Object.assign(window.zen, { setTaskReminders: vi.fn() }); + expect(await openTasksPage()).toEqual({ found: true, rows: 2 }); + }); + it("finds the template folder and picks it from the vault's folders (#1024)", async () => { await act(async () => root.render(createElement(SettingsModal))); const search = host.querySelector('input[placeholder="Search settings…"]')!; @@ -418,22 +447,114 @@ describe("SettingsModal date note directories", () => { await act(async () => result!.click()); const row = host.querySelector('[data-settings-search-id="templates-folder"]'); expect(row?.textContent).toContain("stay out of Tasks"); - const picker = row!.querySelector("[data-template-folder-picker]")!; - expect(picker.textContent).toContain("None"); - await act(async () => picker.click()); + const picker = row!.querySelector("[data-template-folder-picker]")!; + expect(picker.value).toBe(""); + expect(picker.placeholder).toBe("None"); + await act(async () => picker.focus()); // Folders inside an area, by their path on disk: never an area itself, - // never the Trash. - const options = [...document.querySelectorAll("[data-template-folder-option]")]; - expect(options.map((o) => o.dataset.templateFolderOption)).toEqual([ - "", - "archive/Old", + // never the Trash. The folder picker (#1033) lists them on focus. + const options = [...document.querySelectorAll("[data-folder-picker-option]")]; + expect(options.map((o) => o.dataset.folderPickerOption)).toEqual([ "inbox/Templates", "inbox/Templates/Work", + "archive/Old", ]); - await act(async () => options[2].click()); + await act(async () => options[0].click()); expect(mocks.state.setTemplateFolder).toHaveBeenCalledWith("inbox/Templates"); }); + it("picks a Task folder from the vault's folders, by keys alone (#1033)", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "task folders")); + const result = [...host.querySelectorAll("aside nav button")].find( + (button) => button.textContent?.includes("Task folders"), + ); + await act(async () => result!.click()); + const field = host.querySelector('[data-tasks-folder-picker="includedFolders"]')!; + expect(field).toBeTruthy(); + const options = (): string[] => + [...document.querySelectorAll("[data-folder-picker-option]")].map( + (o) => o.dataset.folderPickerOption ?? "", + ); + const highlighted = (): string | null => + document.querySelector('[data-folder-picker-option][aria-selected="true"]') + ?.dataset.folderPickerOption ?? null; + const press = async (key: string, init: KeyboardEventInit = {}): Promise => { + await act(async () => { + field.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init })); + }); + }; + + // Focus lists every folder by its vault path, the areas too; never the Trash. + await act(async () => field.focus()); + expect(options()).toEqual([ + "inbox", + "inbox/Templates", + "inbox/Templates/Work", + "quick", + "archive", + "archive/Old", + ]); + expect(highlighted()).toBeNull(); + + // Typing narrows and highlights the first match; Ctrl+N moves on. + await act(async () => changeInput(field, "temp")); + expect(options()).toEqual(["inbox/Templates", "inbox/Templates/Work"]); + expect(highlighted()).toBe("inbox/Templates"); + await press("n", { ctrlKey: true }); + expect(highlighted()).toBe("inbox/Templates/Work"); + + // Escape closes the list and leaves Settings open. + await press("Escape"); + expect(options()).toEqual([]); + expect(mocks.setSettingsOpen).not.toHaveBeenCalled(); + + // ArrowDown opens it again; Enter adds the highlighted folder. + await press("ArrowDown"); + expect(highlighted()).toBe("inbox/Templates"); + await press("Enter"); + expect(mocks.state.toggleTasksIncludedFolder).toHaveBeenCalledWith("inbox/Templates"); + expect(field.value).toBe(""); + }); + + it("offers the primary area's folders for a specific drawings folder and saves a pick", async () => { + const original = mocks.state.vaultSettings; + try { + mocks.state.vaultSettings = { + ...original, + drawingsLocation: { mode: "folder", folder: "Drawings" }, + } as typeof original; + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "drawings folder")); + const result = [...host.querySelectorAll("aside nav button")].find( + (button) => button.textContent?.includes("Default drawings location"), + ); + await act(async () => result!.click()); + const row = host.querySelector('[data-settings-search-id="drawings-folder"]')!; + expect(row.textContent).toContain("New drawings go to `inbox/Drawings/`."); + const field = row.querySelector('input[role="combobox"]')!; + expect(field.value).toBe("Drawings"); + await act(async () => field.focus()); + // Inbox subpaths, the form the setting stores: no area, no `inbox/`. + const options = [...document.querySelectorAll("[data-folder-picker-option]")]; + expect(options.map((o) => o.dataset.folderPickerOption)).toEqual([ + "Templates", + "Templates/Work", + ]); + await act(async () => options[1].click()); + expect(mocks.setVaultSettings).toHaveBeenCalledWith( + expect.objectContaining({ + drawingsLocation: { mode: "folder", folder: "Templates/Work" }, + }), + ); + expect(field.value).toBe("Templates/Work"); + } finally { + mocks.state.vaultSettings = original; + } + }); + it("offers the template folder on a server vault only when the server keeps it", async () => { mocks.state.workspaceMode = "remote"; const serverWith = (capabilities: Record) => ({ @@ -539,6 +660,7 @@ describe("SettingsModal date note directories", () => { }; Object.assign(window.zen, { getCapabilities: () => ({ supportsHarper: true }), + setTaskReminders: vi.fn(), }); await act(async () => root.render(createElement(SettingsModal))); return vi.mocked(getSettingsSearchResults).mock diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index f67c4aced..e14f78c6f 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -112,6 +112,13 @@ import { dropdownRectForElement, type DropdownRect, } from "../lib/dropdown-placement"; +import { + folderNameChoices, + primaryFolderChoices, + vaultFolderPathChoices, + type FolderChoice, +} from "../lib/folder-picker"; +import { FolderPickerInput } from "./FolderPickerInput"; import { DEFAULT_SYSTEM_FOLDER_LABELS, getSystemFolderLabel, @@ -131,8 +138,8 @@ import { normalizeMonthlyNoteLocale, normalizeMonthlyNoteTitlePattern, normalizeMonthlyNotesDirectory, + reservedCreateFolderTop, specificFolderDestination, - vaultRelativeFolderPath, } from "../lib/vault-layout"; import { BUILTIN_TEMPLATES } from "@shared/builtin-templates"; import { @@ -376,6 +383,20 @@ function specificFolderDestinationLabel( return dir ? `\`${dir}/\`` : "the vault root"; } +/** Where a `Specific folder` row's new files go, and why when a reserved + * folder sends them to the primary location instead. */ +function specificFolderOutcome( + folder: string | undefined, + settings: VaultSettings, + files: string, +): string { + const where = specificFolderDestinationLabel(folder, settings); + const reserved = reservedCreateFolderTop(folder, settings); + if (!reserved) return `New ${files} go to ${where}.`; + const why = reserved.startsWith(".") ? "is hidden" : "holds attachments"; + return `\`${reserved}/\` ${why}, so new ${files} go to ${where}.`; +} + function resolveVaultTextSearchBackend( preferred: VaultTextSearchBackendPreference, capabilities: VaultTextSearchCapabilities | null, @@ -753,6 +774,11 @@ export function SettingsModal(): JSX.Element { const setKanbanLanes = useStore((s) => s.setKanbanLanes); const hideEmptyTasks = useStore((s) => s.hideEmptyTasks); const setHideEmptyTasks = useStore((s) => s.setHideEmptyTasks); + const taskReminders = useStore((s) => s.taskReminders); + const setTaskReminders = useStore((s) => s.setTaskReminders); + const taskReminderTime = useStore((s) => s.taskReminderTime); + const setTaskReminderTime = useStore((s) => s.setTaskReminderTime); + const [reminderTimeIssue, setReminderTimeIssue] = useState(null); const tasksListOrder = useStore((s) => s.tasksListOrder); const setTasksListOrder = useStore((s) => s.setTasksListOrder); const setShowArchivedTasks = useStore((s) => s.setShowArchivedTasks); @@ -849,6 +875,13 @@ export function SettingsModal(): JSX.Element { remoteWorkspaceInfo?.capabilities?.supportsSharesLocation === true); const remoteWorkspaceProfiles = useStore((s) => s.remoteWorkspaceProfiles); const vaultSettings = useStore((s) => s.vaultSettings); + const folders = useStore((s) => s.folders); + // The `Specific folder` rows name a folder inside the primary notes area, + // so their pickers offer exactly those (#1033). + const primaryFolders = useMemo( + () => primaryFolderChoices(folders, vaultSettings), + [folders, vaultSettings], + ); const persistVaultSettings = useStore((s) => s.setVaultSettings); const openVaultPicker = useStore((s) => s.openVaultPicker); const connectRemoteWorkspace = useStore((s) => s.connectRemoteWorkspace); @@ -917,6 +950,10 @@ export function SettingsModal(): JSX.Element { // Undo history between launches needs somewhere machine-local that is not // the vault, which only the desktop app has. (#793) const supportsUndoFile = !!zenBridge.getCapabilities().supportsUndoFile; + // Reminders fire from the desktop app's main process, the only host that + // takes the schedule (the same check as task-reminders-runtime), so the + // phones and the web app leave out a setting that would do nothing there. + const supportsTaskReminders = !!window.zen.setTaskReminders; const [templateEditor, setTemplateEditor] = useState<{ initialRaw?: string; sourcePath?: string; @@ -3721,6 +3758,32 @@ export function SettingsModal(): JSX.Element { "split", ], }, + ...(supportsTaskReminders + ? [ + { + id: "task-reminders", + title: "Task reminders", + description: "Notify for tasks with @remind.", + keywords: [ + "remind", + "reminder", + "notification", + "notify", + "alert", + "alarm", + "due", + "repeat", + "recurring", + ], + }, + { + id: "task-reminder-time", + title: "Reminder time", + description: "For day-before reminders.", + keywords: ["remind", "reminder", "time", "morning", "default", "notification"], + }, + ] + : []), { id: "hide-empty-tasks", title: "Hide empty tasks", @@ -3850,6 +3913,32 @@ export function SettingsModal(): JSX.Element { onChange={(next) => setTasksListOrder(next)} /> + {supportsTaskReminders && ( +
+ + { + const m = /^(\d{1,2}):([0-5]\d)$/.exec(next ?? ""); + const ok = m != null && Number(m[1]) <= 23; + setReminderTimeIssue(ok ? null : "Use HH:MM, like 09:00 or 18:30."); + if (ok) setTaskReminderTime(next ?? ""); + }} + /> +
+ )}
void persistVaultSettings({ @@ -4970,7 +5060,7 @@ export function SettingsModal(): JSX.Element { )} void persistVaultSettings({ @@ -5009,7 +5100,7 @@ export function SettingsModal(): JSX.Element { )} void persistVaultSettings({ @@ -5067,6 +5159,7 @@ export function SettingsModal(): JSX.Element { value={vaultSettings.sharesLocation?.folder ?? ""} placeholder="Clippings" settingId="shares-folder" + folderChoices={primaryFolders} commitOnBlur onChange={(next) => void persistVaultSettings({ @@ -7724,6 +7817,9 @@ function QuickCaptureHotkeyRow({ ); } +const TEXT_INPUT_ROW_FIELD_CLASS = + "w-[23rem] max-w-full shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45"; + function TextInputRow({ label, description, @@ -7732,6 +7828,7 @@ function TextInputRow({ settingId, commitOnBlur = false, issue, + folderChoices, onChange, }: { label: string; @@ -7743,6 +7840,9 @@ function TextInputRow({ /** Why the last value was refused. Shown under the description, in place of * the field quietly reverting and leaving the user to guess. */ issue?: string | null; + /** The vault's folders, listed under the field for a row that names one + * (#1033). A pick saves at once; a typed path saves as before. */ + folderChoices?: readonly FolderChoice[]; onChange: (next: string | null) => void; }): JSX.Element { const [draft, setDraft] = useState(value); @@ -7763,6 +7863,15 @@ function TextInputRow({ onChange(next ? next : null); }; + const onKeyDown = (e: React.KeyboardEvent): void => { + if (!commitOnBlur) return; + if (e.key === "Enter" && !isImeComposing(e)) e.currentTarget.blur(); + if (e.key === "Escape") { + setDraft(value); + e.currentTarget.blur(); + } + }; + // The field wraps under the label when the row is too narrow, as the Vault // Location buttons do (#871). On one unwrapping line, a fixed-width field // in a tablet-width or narrow-window Settings pane squeezed the label and @@ -7788,28 +7897,46 @@ function TextInputRow({ )} - setFocused(true)} - onChange={(e) => { - setDraft(e.target.value); - if (!commitOnBlur) commit(e.target.value); - }} - onBlur={(e) => { - setFocused(false); - if (commitOnBlur) commit(e.target.value); - }} - onKeyDown={(e) => { - if (!commitOnBlur) return; - if (e.key === "Enter" && !isImeComposing(e)) e.currentTarget.blur(); - if (e.key === "Escape") { - setDraft(value); - e.currentTarget.blur(); - } - }} - placeholder={placeholder} - className="w-[23rem] max-w-full shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" - /> + {folderChoices ? ( + setFocused(true)} + onChange={(next) => { + setDraft(next); + if (!commitOnBlur) commit(next); + }} + onPick={(next) => { + setDraft(next); + commit(next); + }} + onBlur={(e) => { + setFocused(false); + if (commitOnBlur) commit(e.target.value); + }} + onKeyDown={onKeyDown} + placeholder={placeholder} + className={TEXT_INPUT_ROW_FIELD_CLASS} + /> + ) : ( + setFocused(true)} + onChange={(e) => { + setDraft(e.target.value); + if (!commitOnBlur) commit(e.target.value); + }} + onBlur={(e) => { + setFocused(false); + if (commitOnBlur) commit(e.target.value); + }} + onKeyDown={onKeyDown} + placeholder={placeholder} + className={TEXT_INPUT_ROW_FIELD_CLASS} + /> + )} ); } @@ -8038,14 +8165,13 @@ function TemplateSelectRow({ ); } -const NO_TEMPLATE_FOLDER = "\0none"; - /** - * Settings editor for the vault's template folder (#1024): a picker over the - * vault's folders (by their path on disk, which is what vault.json stores), - * plus None. The sidebar folder menu ("Use as Template Folder", `m` in Vim - * mode) is the other way in. Saved to vault.json, so it travels with the vault - * and every runtime agrees on which notes are templates. + * Settings editor for the vault's template folder (#1024): a folder picker + * over the vault's folders, by their path on disk, which is what vault.json + * stores (#1033). An empty field is None. The sidebar folder menu ("Use as + * Template Folder", `m` in Vim mode) is the other way in. Saved to vault.json, + * so it travels with the vault and every runtime agrees on which notes are + * templates. */ function TemplateFolderRow({ settingId }: { settingId?: string }): JSX.Element { const vaultSettings = useStore((s) => s.vaultSettings); @@ -8053,127 +8179,30 @@ function TemplateFolderRow({ settingId }: { settingId?: string }): JSX.Element { const folderTemplates = useStore((s) => s.folderTemplates); const setTemplateFolder = useStore((s) => s.setTemplateFolder); const current = resolveTemplateFolder(vaultSettings); + const saved = current ?? ""; // Every folder inside an area, as its path on disk. The areas themselves // (a whole Inbox or Archive of templates) and the Trash are not offered. - const paths = useMemo(() => { - const seen = new Set(); - for (const entry of folders) { - if (entry.folder === "trash" || !entry.subpath) continue; - const rel = vaultRelativeFolderPath(entry.folder, entry.subpath, vaultSettings); - if (rel) seen.add(rel); - } - return [...seen].sort((a, b) => a.localeCompare(b)); - }, [folders, vaultSettings]); - const missing = !!current && !paths.includes(current); - const items = useMemo( - () => [ - NO_TEMPLATE_FOLDER, - ...(missing && current ? [current] : []), - ...paths, - ], - [paths, missing, current], + const choices = useMemo( + () => vaultFolderPathChoices(folders, vaultSettings, { areaRoots: false }), + [folders, vaultSettings], ); - const labelFor = (item: string): string => - item === NO_TEMPLATE_FOLDER - ? "None" - : item === current && missing - ? `${item} (missing)` - : item; - - const [open, setOpen] = useState(false); - const [activeIdx, setActiveIdx] = useState(0); - const buttonRef = useRef(null); - const listRef = useRef(null); - const [rect, setRect] = useState(null); - - useEffect(() => { - if (!open) return; - const at = items.indexOf(current ?? NO_TEMPLATE_FOLDER); - setActiveIdx(at >= 0 ? at : 0); - }, [open, items, current]); - - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent): void => { - const target = e.target as Node; - if (buttonRef.current?.contains(target)) return; - if ( - document.getElementById("zen-template-folder-portal")?.contains(target) - ) - return; - setOpen(false); - }; - window.addEventListener("mousedown", onDown); - return () => window.removeEventListener("mousedown", onDown); - }, [open]); - - useLayoutEffect(() => { - if (!open) return; - const update = (): void => { - const el = buttonRef.current; - if (!el) return; - setRect(dropdownRectForElement(el, { estHeight: items.length * 32 + 8 })); - }; - update(); - window.addEventListener("resize", update); - window.addEventListener("scroll", update, true); - return () => { - window.removeEventListener("resize", update); - window.removeEventListener("scroll", update, true); - }; - }, [open, items.length]); + const missing = !!current && !choices.some((c) => c.value === current); + const [draft, setDraft] = useState(saved); + const [focused, setFocused] = useState(false); + // Escape puts the saved folder back; the blur it ends with must not then + // save what was typed. + const discarding = useRef(false); useEffect(() => { - if (!open || !listRef.current) return; - listRef.current - .querySelector(`[data-idx="${activeIdx}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeIdx, open]); - - const commit = (item: string): void => { - setOpen(false); - buttonRef.current?.focus(); - void setTemplateFolder(item === NO_TEMPLATE_FOLDER ? null : item); - }; + if (!focused) setDraft(saved); + }, [focused, saved]); - const onTriggerKeyDown = ( - e: React.KeyboardEvent, - ): void => { - if (!open) { - if ( - e.key === "ArrowDown" || - e.key === "ArrowUp" || - e.key === "Enter" || - e.key === " " - ) { - e.preventDefault(); - setOpen(true); - } - return; - } - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIdx((i) => Math.min(items.length - 1, i + 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIdx((i) => Math.max(0, i - 1)); - } else if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - const picked = items[activeIdx]; - if (picked !== undefined) commit(picked); - } else if (e.key === "Escape") { - e.preventDefault(); - // Close the list only; Escape reaching Settings would close it too. - e.stopPropagation(); - setOpen(false); - } else if (e.key === "Home") { - e.preventDefault(); - setActiveIdx(0); - } else if (e.key === "End") { - e.preventDefault(); - setActiveIdx(items.length - 1); - } + // An empty field is no template folder. + const commit = (raw: string): void => { + const next = raw.trim(); + if (next === saved) return; + void setTemplateFolder(next ? next : null); }; const count = folderTemplates.length; @@ -8194,84 +8223,34 @@ function TemplateFolderRow({ settingId }: { settingId?: string }): JSX.Element { )} - - - {open && - rect && - createPortal( -
-
- {items.map((item, idx) => { - const isSelected = item === (current ?? NO_TEMPLATE_FOLDER); - return ( - - ); - })} -
-
, - document.body, - )} + placeholder="None" + onFocus={() => setFocused(true)} + onChange={setDraft} + onPick={(next) => { + setDraft(next); + commit(next); + }} + onBlur={(e) => { + setFocused(false); + if (discarding.current) discarding.current = false; + else commit(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !isImeComposing(e)) e.currentTarget.blur(); + if (e.key === "Escape") { + discarding.current = true; + setDraft(saved); + e.currentTarget.blur(); + } + }} + className={TEXT_INPUT_ROW_FIELD_CLASS} + /> ); } @@ -8729,6 +8708,13 @@ function slugifyStatus(name: string): string { function KanbanFolderRootRow({ settingId }: { settingId?: string }): JSX.Element { const root = useStore((s) => s.kanbanFolderRoot); const setKanbanFolderRoot = useStore((s) => s.setKanbanFolderRoot); + const folders = useStore((s) => s.folders); + const vaultSettings = useStore((s) => s.vaultSettings); + // The root is a folder inside the primary notes area, like `Specific folder`. + const choices = useMemo( + () => primaryFolderChoices(folders, vaultSettings), + [folders, vaultSettings], + ); const [draft, setDraft] = useState(root); useEffect(() => { setDraft(root); @@ -8748,13 +8734,18 @@ function KanbanFolderRootRow({ settingId }: { settingId?: string }): JSX.Element file-level key.
- setDraft(e.target.value)} + onChange={setDraft} + onPick={(next) => { + setDraft(next); + setKanbanFolderRoot(next); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") { @@ -8922,15 +8913,28 @@ function TasksFolderListRow({ ? s.toggleTasksIncludedFolder : s.toggleTasksExcludedFolder, ); + const vaultFolders = useStore((s) => s.folders); + const vaultSettings = useStore((s) => s.vaultSettings); const [draft, setDraft] = useState(""); const included = list === "includedFolders"; + // Both lists store paths from the vault root, and an area itself is a fair + // pick (the sidebar offers its menu items on an area too). Folders already + // listed are not offered again. + const choices = useMemo( + () => + vaultFolderPathChoices(vaultFolders, vaultSettings, { + areaRoots: true, + }).filter((choice) => !folders.includes(choice.value)), + [vaultFolders, vaultSettings, folders], + ); - const addDraft = (): void => { - const cleaned = normalizeTasksExcludedFolder(draft); + const add = (raw: string): void => { + const cleaned = normalizeTasksExcludedFolder(raw); if (!cleaned || folders.includes(cleaned)) return; setDraft(""); void toggle(cleaned); }; + const addDraft = (): void => add(draft); return (
@@ -8986,25 +8990,24 @@ function TasksFolderListRow({ ))}
- setDraft(e.target.value)} + choices={choices} + onChange={setDraft} + onPick={add} onKeyDown={(e) => { - if (e.key === "Enter") { + if (e.key === "Enter" && !isImeComposing(e)) { e.preventDefault(); addDraft(); } }} - placeholder={ - included - ? "Folder path, e.g. Tasks" - : "Folder path, e.g. inbox/Books" - } + placeholder="Pick a folder or type its path" aria-label={ included - ? "Folder path to use as a Task folder" - : "Folder path to exclude from Tasks" + ? "Folder to use as a Task folder" + : "Folder to exclude from Tasks" } + data-tasks-folder-picker={list} className="min-w-0 flex-1 rounded-md border border-paper-300 bg-paper-100 px-2.5 py-1.5 text-sm text-ink-900 outline-none focus:border-accent/60" />
- setDraft(e.target.value)} + choices={choices} + current={effective} + onChange={setDraft} + onPick={(next) => { + setDraft(next); + void setTypstPreambleFolder(next); + }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index bf8c1d71b..3cde615b2 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -527,6 +527,8 @@ export function Sidebar(): JSX.Element { const toggleTasksIncludedFolder = useStore((s) => s.toggleTasksIncludedFolder); const setTemplateFolder = useStore((s) => s.setTemplateFolder); const createDatabase = useStore((s) => s.createDatabase); + const createKanban = useStore((s) => s.createKanban); + const newKanban = useStore((s) => s.newKanban); const createNoteInChosenFolder = useStore((s) => s.createNoteInChosenFolder); const openTemplatePaletteForFolder = useStore((s) => s.openTemplatePaletteForFolder); const quickNoteDateTitle = useStore((s) => s.quickNoteDateTitle); @@ -1952,6 +1954,12 @@ export function Sidebar(): JSX.Element { await createDatabase(folder, subpath); }, }, + { + label: "New Kanban", + onSelect: async () => { + await createKanban(folder, subpath); + }, + }, ); if (folder === "quick" && isTop) { items.push({ @@ -2201,6 +2209,7 @@ export function Sidebar(): JSX.Element { createAndOpen, createDrawingAndOpen, createDatabase, + createKanban, openTemplatePaletteForFolder, openArchiveView, openQuickNotesView, @@ -2255,6 +2264,12 @@ export function Sidebar(): JSX.Element { await newDatabase(); }, }, + { + label: "New Kanban", + onSelect: async () => { + await newKanban(); + }, + }, { label: "New folder", onSelect: async () => { @@ -2747,7 +2762,7 @@ export function Sidebar(): JSX.Element { for (const entry of vaultSwitcherEntries) { items.push({ label: entry.name, - hint: entry.current ? "Current" : entry.kind === "remote" ? "Remote" : undefined, + mark: entry.current ? "Current" : entry.kind === "remote" ? "Remote" : undefined, icon: , disabled: entry.current || (entry.kind === "remote" && !entry.id), onSelect: async () => { @@ -3977,7 +3992,7 @@ export function Sidebar(): JSX.Element { label: `Sort by ${label}`, // Re-picking the active field flips its direction, like a // list header; a fresh field starts ascending. - hint: activeField ? (asc ? "↑" : "↓") : undefined, + mark: activeField ? (asc ? "↑" : "↓") : undefined, onSelect: () => state.setAssetSortOrder( activeField && asc ? `${field}-desc` : `${field}-asc`, diff --git a/packages/app-core/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx index 979fd80e8..58203c725 100644 --- a/packages/app-core/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useKeyboardCursor } from '../lib/keyboard-cursor' import { isTagsViewActive, useStore } from '../store' import type { NoteMeta } from '@shared/ipc' import type { TagBlock } from '@shared/tag-blocks' @@ -61,6 +62,7 @@ function folderLabel(note: NoteMeta): string { export function TagView(): JSX.Element { const notes = useStore((s) => s.notes) + const keyCursor = useKeyboardCursor() const activeNote = useStore((s) => s.activeNote) const preambleFolder = useStore((s) => resolveTypstPreambleFolder(s.vaultSettings?.typstPreambles?.folder) @@ -682,7 +684,7 @@ export function TagView(): JSX.Element {
) : ( rows.map((row, i) => { - const isCursor = i === cursorIndex + const isCursor = keyCursor && i === cursorIndex if (row.kind === 'block') { const { note, block } = row return ( diff --git a/packages/app-core/src/components/TaskRecurrenceBadges.tsx b/packages/app-core/src/components/TaskRecurrenceBadges.tsx new file mode 100644 index 000000000..3c17799d6 --- /dev/null +++ b/packages/app-core/src/components/TaskRecurrenceBadges.tsx @@ -0,0 +1,45 @@ +import type { VaultTask } from '@shared/tasks' +import { describeRepeatRule, parseRepeatRule } from '@shared/task-recurrence' +import { describeRemindSpec, parseRemindSpec } from '@shared/task-reminders' +import { useStore } from '../store' +import { BellIcon, RepeatIcon } from './icons' + +/** + * The quiet marks a recurring task wears on the Tasks surfaces (#1036): a + * repeat icon when it repeats (the rule in its tooltip) and a bell with the + * time when it has a reminder. They stand in for the raw `@repeat:` and + * `@remind:` field chips, which read as noise next to the due date. + */ +export function TaskRecurrenceBadges({ task }: { task: VaultTask }): JSX.Element | null { + const timeFormat = useStore((s) => s.timeFormat) + const rule = parseRepeatRule(task.fields?.repeat) + const spec = parseRemindSpec(task.fields?.remind) + if (!rule && !spec) return null + return ( + <> + {rule && ( + + + + )} + {spec && ( + + + )} + + ) +} + +/** Fields the boards and filters leave alone: the recurrence fields are + * scheduling, not a status, so they never become a column or a lane. */ +export const SCHEDULING_FIELDS: ReadonlySet = new Set(['repeat', 'remind']) diff --git a/packages/app-core/src/components/TasksCalendar.tsx b/packages/app-core/src/components/TasksCalendar.tsx index ec96ebade..bf71d6342 100644 --- a/packages/app-core/src/components/TasksCalendar.tsx +++ b/packages/app-core/src/components/TasksCalendar.tsx @@ -14,6 +14,7 @@ * rest of the codebase. */ import { useEffect, useMemo, useRef, useState } from 'react' +import { useKeyboardCursor } from '../lib/keyboard-cursor' import type { VaultTask } from '@shared/tasks' import { bucketTasksByDueDate, @@ -27,6 +28,7 @@ import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' +import { chooseTaskRemind, chooseTaskRepeat } from '../lib/task-recurrence-pickers' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' @@ -108,6 +110,7 @@ export function TasksCalendar({ hiddenEmpty = 0 }: Props): JSX.Element { const monthAnchorIso = useStore((s) => s.tasksCalendarMonthAnchor) + const keyCursor = useKeyboardCursor() const setMonthAnchor = useStore((s) => s.setTasksCalendarMonthAnchor) const selectedDateIso = useStore((s) => s.tasksCalendarSelectedDate) const setSelectedDate = useStore((s) => s.setTasksCalendarSelectedDate) @@ -532,6 +535,12 @@ export function TasksCalendar({ void chooseTaskDueDate(active) return } + // Repeat… and Remind… (#1036). + if (vimMode && (e.key === 'r' || e.key === 'R')) { + consume() + void (e.key === 'r' ? chooseTaskRepeat(active) : chooseTaskRemind(active)) + return + } if (e.key === 'e') { consume() setEditingTaskId(active.id) @@ -825,7 +834,7 @@ export function TasksCalendar({
{selectedTasks.map((task, i) => renderTask(task, { - isActive: i === activeIdx, + isActive: keyCursor && i === activeIdx, buttonRef: i === activeIdx ? focusedTaskRef : null }) )} diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 811931b1d..f405b2182 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -100,20 +100,30 @@ import { useToastStore } from '../lib/toast' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { isKeyboardContextMenuKey, keyboardMenuPosition } from '../lib/keyboard-context-menu' import { listViewBindingMatches } from '../lib/list-view-keys' +import { + KANBAN_COLUMN_COLORS, + kanbanColumnColorCss, + kanbanColumnColorLabel, + kanbanColumnColorVar, + type KanbanColumnColor +} from '../lib/kanban-column-colors' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' +import { chooseTaskRemind, chooseTaskRepeat } from '../lib/task-recurrence-pickers' import { AlertTriangleIcon, ArrowUpRightIcon, ChevronRightIcon, PencilIcon } from './icons' import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' import { InlineMarkdown } from '../lib/inline-markdown' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { TaskStateBox } from './TaskStateBox' import { isImeComposing } from '../lib/ime' +import { useKeyboardCursor } from '../lib/keyboard-cursor' import { createDragAutoScroller } from '../lib/drag-autoscroll' import { getSequenceTokens, sequenceTokenFromEvent, type KeymapOverrides } from '../lib/keymaps' +import { SCHEDULING_FIELDS, TaskRecurrenceBadges } from './TaskRecurrenceBadges' interface Props { tasks: VaultTask[] @@ -127,7 +137,8 @@ interface Props { today: Date onOpenTask: (task: VaultTask) => void onToggleTask: (task: VaultTask) => void - /** Filled while the board is mounted, for the Tasks ex line's `:wip`. */ + /** Filled while the board is mounted, for the Tasks ex line's `:wip` and + * `:color`. */ controlRef?: React.MutableRefObject } @@ -137,6 +148,8 @@ export interface KanbanControl { /** Set or clear the focused column's WIP limit (#1025). Returns that * column's label, or null on a board with no columns. */ setFocusedColumnWipLimit: (limit: number | null) => string | null + /** Set or clear the focused column's colour (#1037), the same way. */ + setFocusedColumnColor: (color: KanbanColumnColor | null) => string | null } // Sentinel column id for the "No status" bucket on the custom-status board. @@ -161,10 +174,11 @@ const NO_SUBTASKS: readonly CardSubtask[] = [] const NO_LANE_IDS: readonly string[] = [] /** The dot beside a lane's label: the card chips' priority colours, a field - * value's column accent, none for tags and folders. */ + * value's column colour or accent, none for tags and folders. */ function laneDotOf( laneBy: KanbanLaneBy, - laneId: string + laneId: string, + columnColors: Record ): { className?: string; color?: string } | null { if (laneBy === 'priority') { if (laneId === 'high') return { className: 'bg-rose-400' } @@ -173,7 +187,8 @@ function laneDotOf( return null } if (laneBy.startsWith('field:')) { - const color = columnAccent(laneId) + const chosen = laneId === NO_VALUE_COLUMN_ID ? undefined : columnColors[`${laneBy}:${laneId}`] + const color = chosen ? kanbanColumnColorCss(chosen) : columnAccent(laneId) return color ? { color } : null } return null @@ -1050,7 +1065,10 @@ export function TasksKanban({ const cardSort = useStore((s) => s.kanbanCardSort) const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) const kanbanWipLimits = useStore((s) => s.kanbanWipLimits) + const keyCursor = useKeyboardCursor() const setKanbanWipLimit = useStore((s) => s.setKanbanWipLimit) + const kanbanColumnColors = useStore((s) => s.kanbanColumnColors) + const setKanbanColumnColor = useStore((s) => s.setKanbanColumnColor) const setCardSort = useStore((s) => s.setKanbanCardSort) const setGroupBy = useStore((s) => s.setKanbanGroupBy) const kanbanColumnTitles = useStore((s) => s.kanbanColumnTitles) @@ -1095,7 +1113,13 @@ export function TasksKanban({ const [columnDropTarget, setColumnDropTarget] = useState<{ id: string; after: boolean } | null>( null ) - const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) + const [menu, setMenu] = useState<{ + x: number + y: number + items: ContextMenuItem[] + /** A heading, for the menu that picks a column's colour. */ + title?: string + } | null>(null) // The subtask Tab stepped onto inside the focused card (#1028), by id so a // rescan that rebuilds the records keeps it; null is the card itself. const [subFocusId, setSubFocusId] = useState(null) @@ -1339,6 +1363,7 @@ export function TasksKanban({ const keys = new Set() for (const t of tasks) if (t.fields) for (const k of Object.keys(t.fields)) keys.add(k) keys.delete('status') + for (const k of SCHEDULING_FIELDS) keys.delete(k) for (const k of [...keys].sort()) { opts.push({ value: `field:${k}` as KanbanGroupBy, label: `By @${k}` }) } @@ -1362,7 +1387,7 @@ export function TasksKanban({ if (!t.fields) continue for (const k of Object.keys(t.fields)) { if (k === 'status') status = true - else keys.add(k) + else if (!SCHEDULING_FIELDS.has(k)) keys.add(k) } } return laneOptions(groupBy, keys, { customStatus: status, current: laneBy }) @@ -1440,11 +1465,52 @@ export function TasksKanban({ [groupBy, setKanbanWipLimit] ) + // ---- Column colours (#1037): the column menu, `C`, and `:color` -------- + // One menu that picks from the palette, the current colour checked, so + // the cursor starts on it and Enter on a name sets it. + const openColumnColorMenu = useCallback( + (column: Column, x: number, y: number): void => { + const current = useStore.getState().kanbanColumnColors[columnOrderKey(groupBy, column.id)] + const items: ContextMenuItem[] = KANBAN_COLUMN_COLORS.map((color) => ({ + label: color.label, + icon: ( +
)} @@ -3379,7 +3494,14 @@ export function TasksKanban({ /> {menu && ( - setMenu(null)} /> + setMenu(null)} + /> )}
) @@ -3420,6 +3542,9 @@ interface CardProps { onToggleSubtask?: (task: VaultTask) => void /** Opens the note at the subtask's line, where its text is edited. */ onOpenSubtask?: (task: VaultTask) => void + /** The column colours (#1037): an `@key:value` chip takes the colour its + * value's column on the `key` board was given. */ + columnColors?: Record } /** The card's subtask summary, always shown: a thin bar and `3/5 · 60%` for @@ -3616,7 +3741,8 @@ function TaskCard({ focusedSubtaskId = null, onFocusSubtask, onToggleSubtask, - onOpenSubtask + onOpenSubtask, + columnColors }: CardProps): JSX.Element { return (
)} + {task.fields && - Object.entries(task.fields).map(([k, v]) => { + Object.entries(task.fields).filter(([k]) => !SCHEDULING_FIELDS.has(k)).map(([k, v]) => { + const chosen = columnColors?.[`field:${k}:${v}`] const accent = columnAccent(v) ?? '#7f8c9a' return ( @{k}:{v} diff --git a/packages/app-core/src/components/TasksRow.tsx b/packages/app-core/src/components/TasksRow.tsx index f1176d1ad..b61598c15 100644 --- a/packages/app-core/src/components/TasksRow.tsx +++ b/packages/app-core/src/components/TasksRow.tsx @@ -3,6 +3,7 @@ import type { VaultTask } from '@shared/tasks' import { ArrowUpRightIcon } from './icons' import { InlineMarkdown } from '../lib/inline-markdown' import { TaskStateBox } from './TaskStateBox' +import { TaskRecurrenceBadges } from './TaskRecurrenceBadges' import { priorityClass, priorityLabel } from '../lib/task-priority' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { getCurrentDragPayload, readDragPayload, setDragPayload } from '../lib/dnd' @@ -151,6 +152,7 @@ export function TasksRow({ {priorityLabel(task.priority)} )} + {task.due && ( s.tasksLoading) const filter = useStore((s) => s.tasksFilter) const cursorIndex = useStore((s) => s.taskCursorIndex) + const keyCursor = useKeyboardCursor() const setFilter = useStore((s) => s.setTasksFilter) const savedFilters = useStore((s) => s.savedTaskFilters) const saveTaskFilter = useStore((s) => s.saveTaskFilter) @@ -133,7 +138,8 @@ export function TasksView(): JSX.Element { () => filterTasks(shownTasks, filter, noteTags), [shownTasks, filter, noteTags] ) - // The board's handle for `:wip`, which needs the column under its cursor. + // The board's handle for `:wip` and `:color`, which need the column under + // its cursor. const kanbanControlRef = useRef(null) const keymapOverrides = useStore((s) => s.keymapOverrides) const vimMode = useStore((s) => s.vimMode) @@ -398,6 +404,13 @@ export function TasksView(): JSX.Element { // deferred, so toggling again within the window simply reverts it in place. const lingerToggle = useCallback( (task: VaultTask) => { + // A recurring task checked off leaves its row to the next occurrence + // (#1036): lingering it would offer a second `x` that checks THAT one + // off. Its undo is the toast's Undo. + if (!task.checked && task.kind !== 'file' && repeatRuleOfLine(task.rawText)) { + void toggleTaskFromList(task) + return + } const key = task.id if (!lingerRef.current.has(key)) lingerRef.current.set(key, { groupChecked: task.checked }) const prev = lingerTimers.current.get(key) @@ -571,6 +584,24 @@ export function TasksView(): JSX.Element { toast(parsed.limit === null ? `${label}: no WIP limit` : `${label}: WIP limit ${parsed.limit}`) return } + // `:color blue` colours the focused column; bare `:color` clears it, + // like the colour menu's No color. (#1037) + if (head === 'color' || head === 'colour') { + const control = kanbanControlRef.current + if (viewMode !== 'kanban' || !control) { + toast(':color works on the board') + return + } + const color = arg ? parseKanbanColumnColor(arg) : null + if (arg && color === null) { + toast('Usage: :color gray, red, yellow, green, teal, blue or purple') + return + } + const label = control.setFocusedColumnColor(color) + if (label === null) return + toast(color === null ? `${label}: no color` : `${label}: ${kanbanColumnColorLabel(color)}`) + return + } // `:lanes priority` splits the board into lanes by a second field // (priority, folder, tag, or any @field); bare `:lanes` turns them off, // like Lanes: None in the View menu. (#1027) @@ -926,6 +957,12 @@ export function TasksView(): JSX.Element { void chooseTaskDueDate(currentTask) return } + // Repeat… and Remind… (#1036). Vim-gated single keys. + if (vimMode && (key === 'r' || key === 'R') && currentTask) { + consume() + void (key === 'r' ? chooseTaskRepeat(currentTask) : chooseTaskRemind(currentTask)) + return + } } window.addEventListener('keydown', handler, true) return () => window.removeEventListener('keydown', handler, true) @@ -1178,7 +1215,7 @@ export function TasksView(): JSX.Element { key={task.id} task={task} isOverdue={overdue} - isCursor={idx === currentRowIdx} + isCursor={keyCursor && idx === currentRowIdx} onToggle={() => lingerToggle(task)} onOpen={() => void openTaskAt(task)} onFocusRow={() => { diff --git a/packages/app-core/src/components/TimelinePanel.tsx b/packages/app-core/src/components/TimelinePanel.tsx index d0f78809b..3dba17b30 100644 --- a/packages/app-core/src/components/TimelinePanel.tsx +++ b/packages/app-core/src/components/TimelinePanel.tsx @@ -19,6 +19,7 @@ * stands down for this panel except for Ctrl+W and the leader. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useKeyboardCursor } from '../lib/keyboard-cursor' import type { NoteMeta } from '@shared/ipc' import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' import { useStore } from '../store' @@ -113,6 +114,7 @@ export function TimelinePanel({ fitWidth?: number }): JSX.Element { const notes = useStore((s) => s.notes) + const keyCursor = useKeyboardCursor() const vaultSettings = useStore((s) => s.vaultSettings) const vaultTasks = useStore((s) => s.vaultTasks) const tasksLoading = useStore((s) => s.tasksLoading) @@ -430,7 +432,7 @@ export function TimelinePanel({
) } - const isCursor = panelFocused && index === cursorIdx + const isCursor = keyCursor && panelFocused && index === cursorIdx return (
( + + + + + + +) + +/** A bell: a task with a reminder (#1036). */ +export const BellIcon = (p: IconProps): JSX.Element => ( + + + + +) + export const SearchIcon = (p: IconProps): JSX.Element => ( diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts index e34b57990..aa87e7504 100644 --- a/packages/app-core/src/lib/browse-actions.ts +++ b/packages/app-core/src/lib/browse-actions.ts @@ -1,3 +1,4 @@ +import type { NoteFolder } from '@bridge-contract/ipc' import { isWorkspaceTransitionPending } from './workspace-transition' import { csvPathForFormDir, @@ -269,6 +270,30 @@ export async function requestDeleteBrowseDirectory( export async function createBrowseDatabase( host: BrowseActionHost, directory?: string +): Promise { + return createDatabaseFromBrowse(host, directory, (folder, subpath, isCurrent) => + useStore.getState().createDatabase(folder, subpath, undefined, isCurrent) + ) +} + +/** + * New Kanban (#1038) from Browse: a database that opens on its Board, placed + * like createBrowseDatabase. With a directory it is the desktop folder menu's + * New Kanban, which the phones' folder sheet offers. + */ +export async function createBrowseKanban( + host: BrowseActionHost, + directory?: string +): Promise { + return createDatabaseFromBrowse(host, directory, (folder, subpath, isCurrent) => + useStore.getState().createKanban(folder, subpath, isCurrent) + ) +} + +async function createDatabaseFromBrowse( + host: BrowseActionHost, + directory: string | undefined, + create: (folder: NoteFolder, subpath: string, isCurrent: () => boolean) => Promise ): Promise { const state = useStore.getState() const target = @@ -283,9 +308,7 @@ export async function createBrowseDatabase( const context = start(host, directory ?? '', true) if (!context) return 'unavailable' try { - await useStore - .getState() - .createDatabase(target.folder, target.subpath, undefined, context.isCurrent) + await create(target.folder, target.subpath, context.isCurrent) return context.isCurrent() ? 'completed' : 'stale' } finally { pending = false diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 89fa47bfb..f60c90106 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -34,6 +34,7 @@ import { attachImageResizeHandle, setImageWidthOnLine } from './image-resize' import { assetTabPath } from './asset-tabs' import { openVaultAssetExternally } from './external-file-link' import { canShowPdfInline } from './pdf-inline' +import { checkOffTaskAtMarker } from './cm-task-recurrence' import { getExcalidrawPreview, parseEmbedSizeHint, @@ -1188,12 +1189,13 @@ class TaskCheckboxWidget extends WidgetType { event.preventDefault() event.stopPropagation() // The state char sits at offset 1 inside the marker. Replace it in - // place — width stays 3, so no doc positions downstream shift. - const stateFrom = this.from + 1 - const stateTo = this.from + 2 - view.dispatch({ - changes: { from: stateFrom, to: stateTo, insert: this.checked ? ' ' : 'x' } - }) + // place: width stays 3, so no doc positions downstream shift. Checking + // off a recurring task instead writes its next occurrence (#1036). + if (!this.checked) { + checkOffTaskAtMarker(view, this.from) + return + } + view.dispatch({ changes: { from: this.from + 1, to: this.from + 2, insert: ' ' } }) }) wrap.append(input) @@ -1280,8 +1282,7 @@ class InProgressMarkerWidget extends WidgetType { span.addEventListener('click', (event) => { event.preventDefault() event.stopPropagation() - const stateFrom = this.from + 1 - view.dispatch({ changes: { from: stateFrom, to: stateFrom + 1, insert: 'x' } }) + checkOffTaskAtMarker(view, this.from) }) return span } diff --git a/packages/app-core/src/lib/cm-task-field-complete.ts b/packages/app-core/src/lib/cm-task-field-complete.ts new file mode 100644 index 000000000..51cbb29aa --- /dev/null +++ b/packages/app-core/src/lib/cm-task-field-complete.ts @@ -0,0 +1,43 @@ +import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete' +import { TASK_LINE_RE } from '@shared/tasklists' + +/** + * Values for the recurring-task fields (#1036) as you type them on a task + * line: `@repeat:` offers the rules, `@remind:` the times. Anything else the + * grammar takes (`3w`, `tue/fri`, `2d-7pm`) still types through; these are + * the common ones and a reminder of the shapes. + */ +const OPTIONS: Record<'repeat' | 'remind', { label: string; detail: string }[]> = { + repeat: [ + { label: 'daily', detail: 'every day' }, + { label: 'weekdays', detail: 'Mon to Fri' }, + { label: 'weekly', detail: 'every 7 days' }, + { label: '2w', detail: 'every 2 weeks' }, + { label: 'monthly', detail: 'every month' }, + { label: 'yearly', detail: 'every year' }, + { label: 'mon/thu', detail: 'on those days' }, + { label: 'after-3d', detail: '3 days after done' } + ], + remind: [ + { label: '9am', detail: 'on the due date' }, + { label: '6pm', detail: 'on the due date' }, + { label: '0d', detail: 'morning of' }, + { label: '1d', detail: 'day before' }, + { label: '2d-6pm', detail: '2 days before, 6 PM' } + ] +} + +const FIELD_AT_CURSOR_RE = /(?:^|\s)@(repeat|remind):([\p{L}\d/_-]*)$/iu + +export function taskFieldSource(context: CompletionContext): CompletionResult | null { + const line = context.state.doc.lineAt(context.pos) + if (!TASK_LINE_RE.test(line.text)) return null + const match = FIELD_AT_CURSOR_RE.exec(line.text.slice(0, context.pos - line.from)) + if (!match) return null + const key = match[1].toLowerCase() as 'repeat' | 'remind' + return { + from: context.pos - match[2].length, + options: OPTIONS[key].map((o) => ({ ...o, type: 'text' })), + validFor: /^[\p{L}\d/_-]*$/u + } +} diff --git a/packages/app-core/src/lib/cm-task-metadata.ts b/packages/app-core/src/lib/cm-task-metadata.ts index 1ec137516..ff693d5df 100644 --- a/packages/app-core/src/lib/cm-task-metadata.ts +++ b/packages/app-core/src/lib/cm-task-metadata.ts @@ -22,7 +22,7 @@ import { } from '@codemirror/view' import { TASK_LINE_RE } from '@shared/tasklists' import { isTagSkippedContext } from './cm-hashtags' -import { scanTaskMetadata, todayIso } from './task-metadata-tokens' +import { fieldChip, scanTaskMetadata, todayIso } from './task-metadata-tokens' function buildDecorations(view: EditorView): DecorationSet { const { state } = view @@ -49,6 +49,7 @@ function buildDecorations(view: EditorView): DecorationSet { // RangeSetBuilder needs ascending, non-overlapping ranges; `scanTaskMetadata` // already returns them in order and the token types never overlap. for (const token of tokens) { + const chip = fieldChip(token) const cls = token.kind === 'priority' ? `cm-task-prio cm-task-prio-${token.level}` @@ -56,8 +57,14 @@ function buildDecorations(view: EditorView): DecorationSet { ? !closed && (token.date ?? '') < today ? 'cm-task-meta cm-task-due-overdue' : 'cm-task-meta cm-task-due' - : 'cm-task-meta cm-task-field' - builder.add(base + token.start, base + token.end, Decoration.mark({ class: cls })) + : chip.variant + ? `cm-task-meta cm-task-${chip.variant}${chip.invalid ? ' cm-task-meta-invalid' : ''}` + : 'cm-task-meta cm-task-field' + builder.add( + base + token.start, + base + token.end, + Decoration.mark({ class: cls, ...(chip.title ? { attributes: { title: chip.title } } : {}) }) + ) } } } diff --git a/packages/app-core/src/lib/cm-task-recurrence.test.ts b/packages/app-core/src/lib/cm-task-recurrence.test.ts new file mode 100644 index 000000000..cc8e5cc25 --- /dev/null +++ b/packages/app-core/src/lib/cm-task-recurrence.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment jsdom + +import { CompletionContext } from '@codemirror/autocomplete' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toggleCheckbox } from './cm-toggle-checkbox' +import { taskFieldSource } from './cm-task-field-complete' +import { checkOffTaskAtMarker, editorRecurrenceContext } from './cm-task-recurrence' + +function viewOf(doc: string, cursor: number, dailyDue?: string): EditorView { + return new EditorView({ + state: EditorState.create({ + doc, + selection: EditorSelection.cursor(cursor), + extensions: dailyDue ? [editorRecurrenceContext.of(() => ({ dailyDue }))] : [] + }) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 10, 1, 9, 0)) +}) +afterEach(() => vi.useRealTimers()) + +describe('checking off a recurring task in the editor (#1036)', () => { + it('Mod+L writes the next occurrence above and keeps the cursor on it', () => { + const doc = '- [ ] Water the plants @repeat:weekly due:2026-11-01\n- [ ] other' + const view = viewOf(doc, 10) + toggleCheckbox(view) + expect(view.state.doc.toString()).toBe( + '- [ ] Water the plants @repeat:weekly due:2026-11-08\n- [x] Water the plants due:2026-11-01\n- [ ] other' + ) + expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(1) + expect(view.state.selection.main.head).toBe(10) + }) + + it('the record cancels the subtasks left open, the copy starts them fresh', () => { + const doc = '- [ ] Review @repeat:weekly due:2026-11-01\n - [x] Inbox\n - [ ] Plan\n- [ ] other' + const view = viewOf(doc, 4) + toggleCheckbox(view) + expect(view.state.doc.toString()).toBe( + '- [ ] Review @repeat:weekly due:2026-11-08\n - [ ] Inbox\n - [ ] Plan\n' + + '- [x] Review due:2026-11-01\n - [x] Inbox\n - [-] Plan\n- [ ] other' + ) + }) + + it('subtasks selected with the task are checked off in the record, in one change', () => { + const doc = '- [ ] Review @repeat:weekly due:2026-11-01\n - [ ] Inbox\n - [ ] Plan\n- [ ] other' + const view = new EditorView({ + state: EditorState.create({ doc, selection: EditorSelection.range(0, doc.indexOf('Inbox') + 2) }) + }) + toggleCheckbox(view) + expect(view.state.doc.toString()).toBe( + '- [ ] Review @repeat:weekly due:2026-11-08\n - [ ] Inbox\n - [ ] Plan\n' + + '- [x] Review due:2026-11-01\n - [x] Inbox\n - [-] Plan\n- [ ] other' + ) + }) + + it('one undo step: the whole edit is a single transaction', () => { + const view = viewOf('- [ ] A @repeat:daily due:2026-11-01', 0) + let transactions = 0 + const counting = new EditorView({ + state: view.state, + dispatchTransactions: (trs, v) => { + transactions += trs.length + v.update(trs) + } + }) + toggleCheckbox(counting) + expect(transactions).toBe(1) + }) + + it('unchecking a record is an ordinary toggle', () => { + const view = viewOf('- [x] A due:2026-11-01', 0) + toggleCheckbox(view) + expect(view.state.doc.toString()).toBe('- [ ] A due:2026-11-01') + }) + + it('a daily note gives a dateless task its date', () => { + const view = viewOf('- [ ] Journal @repeat:daily', 0, '2026-10-31') + toggleCheckbox(view) + expect(view.state.doc.toString()).toBe('- [ ] Journal @repeat:daily due:2026-11-02\n- [x] Journal') + }) + + it('the checkbox widget and the in-progress marker repeat the task too', () => { + const view = viewOf('text\n - [/] Review @repeat:mon due:2026-11-02', 0) + checkOffTaskAtMarker(view, view.state.doc.line(2).from + 4) + expect(view.state.doc.toString()).toBe( + 'text\n - [ ] Review @repeat:mon due:2026-11-09\n - [x] Review due:2026-11-02' + ) + const plain = viewOf('- [ ] Plain', 0) + checkOffTaskAtMarker(plain, 2) + expect(plain.state.doc.toString()).toBe('- [x] Plain') + }) +}) + +describe('taskFieldSource', () => { + const complete = (doc: string) => + taskFieldSource(new CompletionContext(EditorState.create({ doc }), doc.length, false)) + + it('offers rules after @repeat: and times after @remind: on a task line', () => { + expect(complete('- [ ] Rent @repeat:')?.options.map((o) => o.label)).toContain('monthly') + expect(complete('- [ ] Rent @remind:1')?.from).toBe('- [ ] Rent @remind:'.length) + expect(complete('- [ ] Rent @remind:')?.options.map((o) => o.label)).toContain('1d') + }) + + it('stays out of the way elsewhere', () => { + expect(complete('Rent @repeat:')).toBeNull() + expect(complete('- [ ] Rent @status:')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-task-recurrence.ts b/packages/app-core/src/lib/cm-task-recurrence.ts new file mode 100644 index 000000000..d873b893f --- /dev/null +++ b/packages/app-core/src/lib/cm-task-recurrence.ts @@ -0,0 +1,94 @@ +/** + * Checking off a recurring task inside the editor (#1036). + * + * `Mod+L`, the live-preview checkbox and the in-progress marker all check a + * task off from inside CodeMirror. For a `@repeat:` task they make the same + * edit the Tasks views make (`@shared/task-recurrence`): the next occurrence + * goes above, the task's own block becomes the record (its open subtasks + * cancelled). It is one change in one transaction, so one undo takes it all + * back. + * + * Typing an `x` into the brackets by hand is a text edit, not a command, and + * stays one: only these commands repeat a task. + */ +import { Facet, type ChangeSpec, type EditorState } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import { TASK_LINE_RE } from '@shared/tasklists' +import { planRecurringCompletion } from '@shared/task-recurrence' +import { noteFrontmatterDue, toIsoDateLocal } from '@shared/tasks' + +export interface EditorRecurrenceContext { + /** The note's own date when it is a daily note whose tasks take that date. */ + dailyDue?: string +} + +/** What the note editor knows about the note it shows. Editors without it + * (quick capture, table cells) still repeat tasks, counting from the line's + * `due:` or the note's frontmatter, else from today. */ +export const editorRecurrenceContext = Facet.define< + () => EditorRecurrenceContext, + (() => EditorRecurrenceContext) | null +>({ + combine: (providers) => providers[0] ?? null +}) + +export interface RecurringCheckOff { + change: ChangeSpec + /** Length of the new occurrence's line, which starts where the task did. */ + openLineLength: number + /** The last 1-based line the change replaces: the end of the task's subtree. */ + lastLine: number +} + +/** + * The change that checks off the recurring task on 1-based line `lineNumber`, + * or null when that line is not an open task with a `@repeat:` rule. Open + * subtasks `ticked` names (selected along with the task, `Vjj` then Mod+L) + * are written done in the record instead of cancelled. + */ +export function recurringCheckOff( + state: EditorState, + lineNumber: number, + opts: { skip?: boolean; ticked?: (lineNumber: number) => boolean } = {} +): RecurringCheckOff | null { + const line = state.doc.line(lineNumber) + if (!/@repeat:/i.test(line.text)) return null + const head = state.doc.sliceString(0, Math.min(state.doc.length, 4096)) + const context = state.facet(editorRecurrenceContext)?.() ?? {} + const plan = planRecurringCompletion(state.doc.toJSON(), lineNumber - 1, { + today: toIsoDateLocal(new Date()), + fallbackDue: noteFrontmatterDue(head) ?? context.dailyDue, + skip: opts.skip + }) + if (!plan) return null + const last = state.doc.line(lineNumber + plan.replaces - 1) + const recordChildren = plan.recordChildren.map((child, k) => { + if (!opts.ticked?.(lineNumber + 1 + k)) return child + const before = state.doc.line(lineNumber + 1 + k).text.match(TASK_LINE_RE) + return before && (before[2] === ' ' || before[2] === '/') ? `${before[1]}x${before[3]}` : child + }) + return { + change: { + from: line.from, + to: last.to, + insert: [...plan.next, plan.record, ...recordChildren].join('\n') + }, + openLineLength: plan.next[0].length, + lastLine: last.number + } +} + +/** + * Check off the task whose `[` marker starts at `markerFrom`: a recurring task + * repeats, anything else gets its state char set to `x`. For the clickable + * markers, which act on their own line and leave the cursor alone. + */ +export function checkOffTaskAtMarker(view: EditorView, markerFrom: number): void { + const line = view.state.doc.lineAt(markerFrom) + const recurring = recurringCheckOff(view.state, line.number) + if (recurring) { + view.dispatch({ changes: recurring.change, userEvent: 'input' }) + return + } + view.dispatch({ changes: { from: markerFrom + 1, to: markerFrom + 2, insert: 'x' } }) +} diff --git a/packages/app-core/src/lib/cm-tight-list-enter.test.ts b/packages/app-core/src/lib/cm-tight-list-enter.test.ts new file mode 100644 index 000000000..4432f6acc --- /dev/null +++ b/packages/app-core/src/lib/cm-tight-list-enter.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +// +// Enter pressed for real in an editor mounted like EditorPane's (note grammar, +// the Vim-aware markdown keymap, ordered-list renumbering): a list never grows +// blank lines of its own, even once one sits between its first two items. +import { afterEach, describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { noteMarkdown } from './cm-markdown-language' +import { orderedListRenumber } from './cm-ordered-list-renumber' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap' + +const views: EditorView[] = [] +afterEach(() => views.splice(0).forEach((v) => v.destroy())) + +/** `doc` with the caret where `|` is. */ +function mount(marked: string): EditorView { + const anchor = marked.indexOf('|') + if (anchor < 0) throw new Error('no caret in doc') + const view = new EditorView({ + state: EditorState.create({ + doc: marked.slice(0, anchor) + marked.slice(anchor + 1), + selection: { anchor }, + extensions: [ + noteMarkdown(), + vimAwareMarkdownKeymap, + orderedListRenumber, + keymap.of([...vimAwareDefaultKeymap(false)]) + ] + }), + parent: document.body + }) + views.push(view) + view.focus() + return view +} + +function pressEnter(view: EditorView, shiftKey = false): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, shiftKey, bubbles: true, cancelable: true }) + ) +} + +/** The doc with `|` at the caret. */ +function marked(view: EditorView): string { + const head = view.state.selection.main.head + const doc = view.state.doc.toString() + return doc.slice(0, head) + '|' + doc.slice(head) +} + +function type(view: EditorView, text: string): void { + const head = view.state.selection.main.head + view.dispatch({ changes: { from: head, insert: text }, selection: { anchor: head + text.length } }) +} + +describe('Enter in a list keeps it tight', () => { + it('opens the next bullet right below once a blank line sits between the first two', () => { + const view = mount('- one\n\n- two|') + pressEnter(view) + expect(marked(view)).toBe('- one\n\n- two\n- |') + type(view, 'three') + pressEnter(view) + expect(marked(view)).toBe('- one\n\n- two\n- three\n- |') + }) + + it('replays the Discord report: Shift+Enter after the first bullet spaces only that gap', () => { + const view = mount('- one|\n- two') + pressEnter(view, true) + expect(marked(view)).toBe('- one\n|\n- two') + view.dispatch({ selection: { anchor: view.state.doc.length } }) + pressEnter(view) + type(view, 'three') + pressEnter(view) + type(view, 'four') + expect(view.state.doc.toString()).toBe('- one\n\n- two\n- three\n- four') + }) + + it('leaves a tight list as it was', () => { + const view = mount('- one\n- two|') + pressEnter(view) + expect(marked(view)).toBe('- one\n- two\n- |') + }) + + it('splits an item mid-text without a gap', () => { + const view = mount('- one\n\n- two|three') + pressEnter(view) + expect(marked(view)).toBe('- one\n\n- two\n- |three') + }) + + it('still renumbers the ordered items after the new one', () => { + const view = mount('1. a\n\n2. b|\n3. c') + pressEnter(view) + expect(marked(view)).toBe('1. a\n\n2. b\n3. |\n4. c') + }) + + it('keeps the quote marks of a quoted list', () => { + const view = mount('> - a\n>\n> - b|') + pressEnter(view) + expect(marked(view)).toBe('> - a\n>\n> - b\n> - |') + }) + + it('continues a task as a fresh box without a gap', () => { + const view = mount('- [x] one\n\n- [ ] two|') + pressEnter(view) + expect(marked(view)).toBe('- [x] one\n\n- [ ] two\n- [ ] |') + }) + + it('ends the list on an empty second item instead of pushing it down', () => { + const view = mount('- one\n- |') + pressEnter(view) + expect(marked(view)).toBe('- one\n|') + }) + + it('ends the list on an empty later item, as before', () => { + const view = mount('- one\n- two\n- |') + pressEnter(view) + expect(marked(view)).toBe('- one\n- two\n|') + }) +}) diff --git a/packages/app-core/src/lib/cm-tight-list-enter.ts b/packages/app-core/src/lib/cm-tight-list-enter.ts new file mode 100644 index 000000000..4c3105887 --- /dev/null +++ b/packages/app-core/src/lib/cm-tight-list-enter.ts @@ -0,0 +1,56 @@ +import { insertNewlineContinueMarkupCommand } from '@codemirror/lang-markdown' +import { ChangeSet, type ChangeSpec, type StateCommand, type Transaction } from '@codemirror/state' + +// Off, Enter on an empty second item ends the list the way it already does on +// an empty third one, instead of pushing the item down under a blank line. +const continueMarkup = insertNewlineContinueMarkupCommand({ nonTightLists: false }) + +/** + * The markdown Enter command, without the blank lines CodeMirror gives a + * "loose" list. + * + * CodeMirror calls a list loose when a blank line separates its first two + * items, and from then on opens every new item in it under a blank line of its + * own. So one Shift+Enter between the first two bullets spaced out every bullet + * typed after it (SLP on Discord, 2026-10-31). Here Enter opens the next item + * right below, as Vim's `o` already did; a blank line the writer typed stays + * where it is, and nothing adds more. + * + * Upstream inserts that blank line, and no other, as a line break, a line of + * nothing but indentation and `>` marks, then the line break before the new + * marker. It is taken out of the finished transaction, so ordered-list + * renumbering and the rest of the edit stay upstream's. + * + * A state command, like the fence one: Vim's `o`/`O` run it on a scratch state. + */ +export const insertNewlineContinueMarkupTight: StateCommand = ({ state, dispatch }) => { + const out: { tr?: Transaction } = {} + const handled = continueMarkup({ + state, + dispatch: (tr) => { + out.tr = tr + } + }) + if (!handled || !out.tr) return handled + const { tr } = out + const gaps: ChangeSpec[] = [] + tr.changes.iterChanges((_fromA, _toA, fromB, _toB, inserted) => { + if (inserted.lines !== 3 || inserted.line(1).length > 0) return + const blank = inserted.line(2) + if (/^[\s>]*$/.test(blank.text)) gaps.push({ from: fromB + 1, to: fromB + blank.length + 2 }) + }) + if (gaps.length === 0) { + dispatch(tr) + return true + } + const drop = ChangeSet.of(gaps, tr.newDoc.length) + dispatch( + state.update({ + changes: tr.changes.compose(drop), + selection: tr.newSelection.map(drop), + scrollIntoView: true, + userEvent: 'input' + }) + ) + return true +} diff --git a/packages/app-core/src/lib/cm-toggle-checkbox.ts b/packages/app-core/src/lib/cm-toggle-checkbox.ts index 3b3d53aa5..fd9d14676 100644 --- a/packages/app-core/src/lib/cm-toggle-checkbox.ts +++ b/packages/app-core/src/lib/cm-toggle-checkbox.ts @@ -14,10 +14,13 @@ * * Each line contributes one minimal change (a single state character, or one * marker insertion), so cursors and selections map through naturally and the - * whole toggle is a single undo step. + * whole toggle is a single undo step. The exception is checking off a + * recurring task (#1036): its block is rewritten with the next occurrence + * above it, and a lone cursor on it stays on the open occurrence. */ import type { ChangeSpec } from '@codemirror/state' import type { EditorView } from '@codemirror/view' +import { recurringCheckOff } from './cm-task-recurrence' // Leading whitespace plus any run of blockquote markers (`> `, nestable). const LINE_PREFIX_RE = /^([ \t]*(?:>[ \t]?)*)/ @@ -55,6 +58,12 @@ export function toggleCheckbox(view: EditorView): boolean { const { state } = view const changes: ChangeSpec[] = [] const seen = new Set() + let stayOn: { from: number; column: number; length: number } | null = null + const selected = new Set() + for (const range of state.selection.ranges) { + const last = state.doc.lineAt(range.to).number + for (let n = state.doc.lineAt(range.from).number; n <= last; n += 1) selected.add(n) + } for (const range of state.selection.ranges) { const firstLine = state.doc.lineAt(range.from).number const lastLine = state.doc.lineAt(range.to).number @@ -63,11 +72,37 @@ export function toggleCheckbox(view: EditorView): boolean { seen.add(n) const line = state.doc.line(n) const change = checkboxToggleChange(line.text, line.from) - if (change) changes.push(change) + if (!change) continue + const recurring = isCheckOff(change) + ? recurringCheckOff(state, n, { ticked: (ln) => selected.has(ln) }) + : null + if (recurring) { + changes.push(recurring.change) + // The change rewrites the task's whole subtree; a selected subtask + // inside it is already handled and must not add a second change. + for (let m = n + 1; m <= recurring.lastLine; m += 1) seen.add(m) + if (state.selection.ranges.length === 1 && range.empty) { + stayOn = { from: line.from, column: range.head - line.from, length: recurring.openLineLength } + } + } else { + changes.push(change) + } } } if (changes.length > 0) { - view.dispatch({ changes, userEvent: 'input' }) + view.dispatch({ + changes, + userEvent: 'input', + // The whole line is replaced, which would map the cursor to its start. + ...(stayOn + ? { selection: { anchor: stayOn.from + Math.min(stayOn.column, stayOn.length) } } + : {}) + }) } return true } + +/** True when `change` writes the `x` that checks a task off. */ +function isCheckOff(change: ChangeSpec): boolean { + return typeof change === 'object' && 'insert' in change && change.insert === 'x' && change.to !== undefined +} diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts index 8cc1d5999..6455c84a8 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -12,6 +12,7 @@ import { import { getCM } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' import { insertNewlineContinueFrontmatterList } from './cm-frontmatter' +import { insertNewlineContinueMarkupTight } from './cm-tight-list-enter' import { selectNoteText } from './cm-select-note' import { isMacPlatform } from './keymaps' @@ -277,10 +278,22 @@ const frontmatterListEnter: KeyBinding = { run: insertNewlineContinueFrontmatterList } +// Upstream's Enter, except that a list never grows blank lines of its own +// (see `insertNewlineContinueMarkupTight`). +const tightListEnter: KeyBinding = { + key: 'Enter', + run: insertNewlineContinueMarkupTight +} + export const vimAwareMarkdownKeymap: Extension = Prec.high( keymap.of( deferKeysToVim( - [frontmatterListEnter, fencedCodeIndentEnter, ...markdownKeymap], + [ + frontmatterListEnter, + fencedCodeIndentEnter, + tightListEnter, + ...markdownKeymap.filter((binding) => binding.key !== 'Enter') + ], new Set(['Enter', 'Backspace']) ) ) diff --git a/packages/app-core/src/lib/cm-vim-open-line.ts b/packages/app-core/src/lib/cm-vim-open-line.ts index 830d83410..ee871d4a3 100644 --- a/packages/app-core/src/lib/cm-vim-open-line.ts +++ b/packages/app-core/src/lib/cm-vim-open-line.ts @@ -1,17 +1,17 @@ import { syntaxTree } from '@codemirror/language' -import { insertNewlineContinueMarkup } from '@codemirror/lang-markdown' import type { EditorState, StateCommand, Transaction } from '@codemirror/state' import type { EditorView } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' import { Vim } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' +import { insertNewlineContinueMarkupTight } from './cm-tight-list-enter' import { listContinuationPrefix } from './list-continuation' // Enter's markup continuations, in vimAwareMarkdownKeymap's order. The // frontmatter list one is left out: the frontmatter is never in a quote. const enterContinuations: readonly StateCommand[] = [ insertNewlineContinueFencedCodeIndent, - insertNewlineContinueMarkup + insertNewlineContinueMarkupTight ] function lineInQuote(state: EditorState, lineNumber: number): boolean { @@ -40,8 +40,8 @@ function enterPrefix(state: EditorState, lineNumber: number): string | null { const { changes, state: next } = out.tr const head = next.selection.main.head const landed = next.doc.lineAt(head) - // An empty item's Enter can push it down a line (loosening the list) - // and leave the caret on it: that line is this one, not a new one. + // An empty item's Enter ends the list and leaves the caret on that + // line: it is this one, not a new one. const own = next.doc.lineAt(changes.mapPos(line.from, 1)) return landed.number > own.number ? next.sliceDoc(landed.from, head) : null } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 51c5aa2fb..89603553d 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -27,6 +27,7 @@ import { focusPaneInDirection } from './pane-nav' import { focusSidebarPanel } from './sidebar-focus' import { runSidebarNotePlan, sidebarCommandPlan } from './sidebar-note-actions' import { openContextGallery } from './folder-gallery-open' +import { newContextKanban } from './kanban-create' import { findLeaf } from './pane-layout' import { getTabStepTarget, stepActiveTab } from './buffer-navigation' import { requestPaneMode } from './pane-mode' @@ -34,6 +35,7 @@ import { resolveQuickNoteTitle } from './quick-note-title' import { forwardTaskWithPicker, taskAtEditorCursor } from './forward-task' import { canManageWorkflows } from './workflow-workspace' import { toggleCheckbox } from './cm-toggle-checkbox' +import { remindTaskAtCursor, repeatTaskAtCursor } from './editor-task-recurrence' import { harperAddWordAtCursor, harperApplyFirstSuggestion, @@ -235,6 +237,16 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'database table csv records spreadsheet board kanban base', run: () => void getState().newDatabase() }, + { + id: 'database.new.kanban', + title: 'New Kanban', + category: 'Note', + shortcut: leaderShortcut('vim.leaderNewKanban'), + keywords: 'kanban board columns cards todo doing done project database base', + // The folder under the sidebar cursor while the sidebar has focus, + // otherwise where new databases go (lib/kanban-create.ts). + run: () => void newContextKanban() + }, { id: 'task.new', title: 'New Task', @@ -1282,6 +1294,28 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma if (view) toggleCheckbox(view) } }, + { + id: 'task.repeat', + title: 'Repeat Task…', + category: 'Editor', + keywords: 'repeat recurring recurrence every daily weekly monthly yearly task schedule', + when: hasEditorNote, + run: () => { + const view = getState().editorViewRef + if (view) void repeatTaskAtCursor(view) + } + }, + { + id: 'task.remind', + title: 'Remind Task…', + category: 'Editor', + keywords: 'remind reminder notification notify alarm alert task due', + when: hasEditorNote, + run: () => { + const view = getState().editorViewRef + if (view) void remindTaskAtCursor(view) + } + }, { id: 'editor.reflow-paragraph', title: 'Reflow Paragraph', diff --git a/packages/app-core/src/lib/database-board.test.ts b/packages/app-core/src/lib/database-board.test.ts new file mode 100644 index 000000000..3879d2d0b --- /dev/null +++ b/packages/app-core/src/lib/database-board.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { EMPTY_GROUP, type DatabaseDoc, type DbRow } from '@shared/databases' +import type { BoardColumn } from '@shared/database-transforms' +import { + addBoardCard, + databaseBoardKeyAction, + resolveBoardCursor, + stepBoardCursor, + visibleBoardColumns +} from './database-board' + +const row = (id: string): DbRow => ({ id, cells: { id } }) +const column = (key: string, ids: string[]): BoardColumn => ({ key, rows: ids.map(row) }) + +describe('databaseBoardKeyAction', () => { + it('answers the Tasks board letters only with Vim mode on', () => { + const vim: Array<[string, string]> = [ + ['h', 'left'], + ['l', 'right'], + ['k', 'up'], + ['j', 'down'], + ['H', 'move-left'], + ['L', 'move-right'], + ['o', 'open'], + ['a', 'add'], + ['m', 'menu'] + ] + for (const [key, action] of vim) { + expect(databaseBoardKeyAction({ key }, true)).toBe(action) + expect(databaseBoardKeyAction({ key }, false)).toBeNull() + } + }) + + it('keeps the arrows, Enter and the menu keys in either mode', () => { + for (const vimMode of [true, false]) { + expect(databaseBoardKeyAction({ key: 'ArrowLeft' }, vimMode)).toBe('left') + expect(databaseBoardKeyAction({ key: 'ArrowRight' }, vimMode)).toBe('right') + expect(databaseBoardKeyAction({ key: 'ArrowUp' }, vimMode)).toBe('up') + expect(databaseBoardKeyAction({ key: 'ArrowDown' }, vimMode)).toBe('down') + expect(databaseBoardKeyAction({ key: 'Enter' }, vimMode)).toBe('open') + expect(databaseBoardKeyAction({ key: 'ContextMenu' }, vimMode)).toBe('menu') + expect(databaseBoardKeyAction({ key: 'F10', shiftKey: true }, vimMode)).toBe('menu') + expect(databaseBoardKeyAction({ key: 'F10' }, vimMode)).toBeNull() + } + }) + + it('never takes a chord', () => { + expect(databaseBoardKeyAction({ key: 'l', metaKey: true }, true)).toBeNull() + expect(databaseBoardKeyAction({ key: 'ArrowLeft', ctrlKey: true }, true)).toBeNull() + expect(databaseBoardKeyAction({ key: 'Enter', altKey: true }, false)).toBeNull() + }) +}) + +describe('visibleBoardColumns', () => { + it('draws No status only while a card sits in it', () => { + const empty = [column('To do', []), column(EMPTY_GROUP, [])] + expect(visibleBoardColumns(empty).map((c) => c.key)).toEqual(['To do']) + const used = [column('To do', []), column(EMPTY_GROUP, ['r1'])] + expect(visibleBoardColumns(used).map((c) => c.key)).toEqual(['To do', EMPTY_GROUP]) + }) +}) + +describe('board cursor', () => { + const cols = [column('To do', ['a', 'b', 'c']), column('Doing', ['d']), column('Done', [])] + + it('starts on the first card, and an empty column puts it on Add card', () => { + expect(resolveBoardCursor(cols, null)).toEqual({ column: 0, card: 0 }) + expect(resolveBoardCursor(cols, { columnKey: 'Done', rowId: null })).toEqual({ column: 2, card: 0 }) + expect(resolveBoardCursor([], null)).toEqual({ column: -1, card: -1 }) + }) + + it('falls back to the first card when its card or column is gone', () => { + expect(resolveBoardCursor(cols, { columnKey: 'Doing', rowId: 'zz' })).toEqual({ column: 1, card: 0 }) + expect(resolveBoardCursor(cols, { columnKey: 'Gone', rowId: 'a' })).toEqual({ column: 0, card: 0 }) + }) + + it('walks the cards, and down from the last one reaches Add card', () => { + let c = stepBoardCursor(cols, { columnKey: 'To do', rowId: 'b' }, 'down') + expect(c).toEqual({ columnKey: 'To do', rowId: 'c' }) + c = stepBoardCursor(cols, c, 'down') + expect(c).toEqual({ columnKey: 'To do', rowId: null }) + expect(stepBoardCursor(cols, c, 'down')).toEqual({ columnKey: 'To do', rowId: null }) + expect(stepBoardCursor(cols, c, 'up')).toEqual({ columnKey: 'To do', rowId: 'c' }) + expect(stepBoardCursor(cols, { columnKey: 'To do', rowId: 'a' }, 'up')).toEqual({ columnKey: 'To do', rowId: 'a' }) + }) + + it('keeps its height across columns where it can, and stays on Add card', () => { + expect(stepBoardCursor(cols, { columnKey: 'To do', rowId: 'c' }, 'right')).toEqual({ columnKey: 'Doing', rowId: 'd' }) + expect(stepBoardCursor(cols, { columnKey: 'Doing', rowId: 'd' }, 'right')).toEqual({ columnKey: 'Done', rowId: null }) + expect(stepBoardCursor(cols, { columnKey: 'Done', rowId: null }, 'left')).toEqual({ columnKey: 'Doing', rowId: null }) + expect(stepBoardCursor(cols, { columnKey: 'Done', rowId: null }, 'right')).toEqual({ columnKey: 'Done', rowId: null }) + expect(stepBoardCursor([], null, 'left')).toBeNull() + }) +}) + +describe('addBoardCard', () => { + const doc: DatabaseDoc = { + version: 1, + path: 'Biology/Untitled Kanban.base/data.csv', + title: 'Untitled Kanban', + idFieldId: 'id', + fields: [ + { id: 'id', name: 'id', type: 'text', hidden: true }, + { id: 'name', name: 'Name', type: 'text' }, + { id: 'status', name: 'Status', type: 'select', options: [{ id: 'o1', value: 'Doing' }] } + ], + views: [{ id: 'board', name: 'Board', type: 'board', filters: [], sorts: [], groupByFieldId: 'status' }], + activeViewId: 'board', + rows: [{ id: 'r1', cells: { id: 'r1', name: 'Old', status: 'Doing' } }] + } + + it('appends a titled card in the column', () => { + const added = addBoardCard(doc, 'board', 'name', 'status', 'Doing', 'Read chapter 4') + expect(added.picksChanged).toBe(false) + expect(added.doc.rows).toHaveLength(2) + const card = added.doc.rows[1] + expect(card.id).toBe(added.rowId) + expect(card.cells).toEqual({ id: added.rowId, name: 'Read chapter 4', status: 'Doing' }) + }) + + it('leaves the status empty in No status, and joins a view’s picks', () => { + const picked = { ...doc, views: [{ ...doc.views[0], pickedRowIds: ['r1'] }] } + const added = addBoardCard(picked, 'board', 'name', 'status', EMPTY_GROUP, 'Loose') + expect(added.doc.rows[1].cells.status).toBe('') + expect(added.picksChanged).toBe(true) + expect(added.doc.views[0].pickedRowIds).toEqual(['r1', added.rowId]) + }) +}) diff --git a/packages/app-core/src/lib/database-board.ts b/packages/app-core/src/lib/database-board.ts new file mode 100644 index 000000000..da516ad0d --- /dev/null +++ b/packages/app-core/src/lib/database-board.ts @@ -0,0 +1,140 @@ +/** + * The database Board's keyboard and its cards (#1038, New Kanban). + * + * The keys follow the Tasks Kanban board, so one muscle memory drives both: + * h / l across columns, j / k between cards, H / L carry the card a column, + * o opens its page, a adds a card, m opens its menu. Like every list in the + * app the letters are Vim mode's alone; with Vim mode off the arrows move, + * Enter opens (or adds a card in an empty column), and Shift+F10 or the + * Context Menu key opens the card's menu, which can also move it. A chord is + * never the board's. + * + * Pure, so the gating and the cursor rules are tested apart from the view. + */ +import { EMPTY_GROUP } from '@shared/databases' +import type { BoardColumn } from '@shared/database-transforms' +import type { DatabaseDoc } from '@shared/databases' +import { addRowInView, setCell } from './database-cells' + +export type DatabaseBoardKeyAction = + | 'left' + | 'right' + | 'up' + | 'down' + | 'move-left' + | 'move-right' + | 'open' + | 'add' + | 'menu' + +export interface BoardKey { + key: string + shiftKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + altKey?: boolean +} + +const UNIVERSAL = new Map(Object.entries({ + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + ArrowDown: 'down', + Enter: 'open', + ContextMenu: 'menu' +}) as Array<[string, DatabaseBoardKeyAction]>) + +const VIM_ONLY = new Map(Object.entries({ + h: 'left', + l: 'right', + k: 'up', + j: 'down', + H: 'move-left', + L: 'move-right', + o: 'open', + a: 'add', + m: 'menu' +}) as Array<[string, DatabaseBoardKeyAction]>) + +export function databaseBoardKeyAction(e: BoardKey, vimMode: boolean): DatabaseBoardKeyAction | null { + if (e.metaKey || e.ctrlKey || e.altKey) return null + if (e.key === 'F10') return e.shiftKey ? 'menu' : null + const universal = UNIVERSAL.get(e.key) + if (universal) return universal + if (!vimMode) return null + return VIM_ONLY.get(e.key) ?? null +} + +/** The columns a board draws: the "No status" column only while a card + * sits in it, so a fresh board fits its own columns. */ +export function visibleBoardColumns(columns: BoardColumn[]): BoardColumn[] { + return columns.filter((column) => column.key !== EMPTY_GROUP || column.rows.length > 0) +} + +/** The board cursor: a column and one of its cards, or the column's Add + * card slot below them (`rowId` null). Kept by key and row id so it + * follows a card across writes. */ +export interface BoardCursor { + columnKey: string + rowId: string | null +} + +/** Column and card indices for `cursor` on `columns`. A card index equal to + * the column's card count is its Add card slot, which is where an empty + * column's cursor sits. A cursor naming a column or card that is gone falls + * back to the first card of the first column. */ +export function resolveBoardCursor( + columns: BoardColumn[], + cursor: BoardCursor | null +): { column: number; card: number } { + if (columns.length === 0) return { column: -1, card: -1 } + const found = columns.findIndex((c) => c.key === cursor?.columnKey) + const column = Math.max(0, found) + const rows = columns[column].rows + if (found >= 0 && cursor?.rowId === null) return { column, card: rows.length } + const card = rows.findIndex((r) => r.id === cursor?.rowId) + return { column, card: Math.max(0, card) } +} + +/** The cursor after an arrow or h / j / k / l. Down from a column's last card + * reaches its Add card slot. Across columns the cursor keeps its height + * where the next column is tall enough, and stays on the slot when it is + * on one. */ +export function stepBoardCursor( + columns: BoardColumn[], + cursor: BoardCursor | null, + action: 'left' | 'right' | 'up' | 'down' +): BoardCursor | null { + const at = resolveBoardCursor(columns, cursor) + if (at.column < 0) return null + let column = at.column + let card = at.card + if (action === 'left' || action === 'right') { + const onSlot = card === columns[column].rows.length + column = Math.max(0, Math.min(columns.length - 1, column + (action === 'left' ? -1 : 1))) + const count = columns[column].rows.length + card = onSlot ? count : Math.min(card, Math.max(count - 1, 0)) + } else { + card = Math.max(0, Math.min(columns[column].rows.length, card + (action === 'up' ? -1 : 1))) + } + return { columnKey: columns[column].key, rowId: columns[column].rows[card]?.id ?? null } +} + +/** + * A new card titled `title` in the column `columnKey`, appended after the + * column's cards. Like + Row, a card added in a view of picked rows joins + * the picks (`picksChanged` then means the schema must be written too). + */ +export function addBoardCard( + doc: DatabaseDoc, + viewId: string, + titleFieldId: string | undefined, + groupFieldId: string, + columnKey: string, + title: string +): { doc: DatabaseDoc; rowId: string; picksChanged: boolean } { + const added = addRowInView(doc, viewId) + let next = setCell(added.doc, added.rowId, groupFieldId, columnKey === EMPTY_GROUP ? '' : columnKey) + if (titleFieldId) next = setCell(next, added.rowId, titleFieldId, title) + return { doc: next, rowId: added.rowId, picksChanged: added.picksChanged } +} diff --git a/packages/app-core/src/lib/editor-task-recurrence.ts b/packages/app-core/src/lib/editor-task-recurrence.ts new file mode 100644 index 000000000..901413b9e --- /dev/null +++ b/packages/app-core/src/lib/editor-task-recurrence.ts @@ -0,0 +1,100 @@ +/** + * Repeat and Remind for the task under the editor's cursor (#1036): the + * `:repeat` / `:remind` ex commands and the command palette's "Repeat Task…" / + * "Remind Task…". With an argument (`:repeat weekly`, `:remind off`) the field + * is written at once; without one the same picker the Tasks views use opens. + */ +import type { EditorView } from '@codemirror/view' +import { TASK_LINE_RE } from '@shared/tasklists' +import { parseRepeatRule, setRemindField, setRepeatField } from '@shared/task-recurrence' +import { parseRemindSpec } from '@shared/task-reminders' +import { noteFrontmatterDue } from '@shared/tasks' +import { editorRecurrenceContext } from './cm-task-recurrence' +import { useToastStore } from './toast' +import { noDueNotice, promptRemindWhen, promptRepeatRule } from './task-recurrence-pickers' + +type TaskFieldKey = 'repeat' | 'remind' + +function toast(message: string): void { + useToastStore.getState().addToast(message, 'info') +} + +/** The task line under the main cursor: its 1-based number and parts. */ +function cursorTask(view: EditorView): { number: number; match: RegExpMatchArray } | null { + const line = view.state.doc.lineAt(view.state.selection.main.head) + const match = line.text.match(TASK_LINE_RE) + return match && match[3].startsWith(']') ? { number: line.number, match } : null +} + +function fieldOf(tail: string, key: TaskFieldKey): string | undefined { + return new RegExp(`(?:^|\\s)@${key}:([\\p{L}\\d][\\p{L}\\d/_-]*)`, 'iu').exec(tail)?.[1]?.toLowerCase() +} + +/** Write `value` (null removes it) as the `@key:` field of task line `number`, + * keeping the cursor where it was on the line. False when it is no task. */ +function writeField(view: EditorView, number: number, key: TaskFieldKey, value: string | null): boolean { + if (number > view.state.doc.lines) return false + const line = view.state.doc.line(number) + const match = line.text.match(TASK_LINE_RE) + if (!match || !match[3].startsWith(']')) return false + const tailFrom = line.from + match[1].length + match[2].length + 1 + const tail = line.text.slice(tailFrom - line.from) + const next = key === 'repeat' ? setRepeatField(tail, value) : setRemindField(tail, value) + if (next === tail) return true + const head = view.state.selection.main.head + const onLine = head >= line.from && head <= line.to + view.dispatch({ + changes: { from: tailFrom, to: line.to, insert: next }, + ...(onLine ? { selection: { anchor: Math.min(head, tailFrom + next.length) } } : {}), + userEvent: 'input' + }) + return true +} + +async function setCursorTaskField(view: EditorView, key: TaskFieldKey, arg: string): Promise { + const task = cursorTask(view) + if (!task) { + toast('Put the cursor on a task first.') + return + } + const tail = task.match[3].slice(1) + const current = fieldOf(tail, key) + let value: string | null | undefined + const typed = arg.trim().toLowerCase() + if (typed) { + value = typed === 'off' ? null : typed + const valid = value == null || (key === 'repeat' ? parseRepeatRule(value) : parseRemindSpec(value)) + if (!valid) { + toast(key === 'repeat' ? 'Try weekly, 2w, mon/thu or after-3d' : 'Try 9am, 1730, 1d or 2d-6pm') + return + } + } else { + const description = tail.trim() + value = + key === 'repeat' + ? await promptRepeatRule(description, current) + : await promptRemindWhen(description, current) + if (value === undefined) return + view.focus() + } + if (!writeField(view, task.number, key, value)) return + if (key === 'remind' && value && !taskHasDate(view, tail)) noDueNotice() +} + +/** Whether a task line has a date to be reminded of: its own `due:`, the + * note's frontmatter `due:`, or the daily note's date. */ +function taskHasDate(view: EditorView, tail: string): boolean { + if (/(?:^|\s)due:\s*\d{4}-\d{2}-\d{2}/i.test(tail)) return true + const head = view.state.doc.sliceString(0, Math.min(view.state.doc.length, 4096)) + return Boolean(noteFrontmatterDue(head) ?? view.state.facet(editorRecurrenceContext)?.().dailyDue) +} + +/** `:repeat [rule|off]`: set how the task under the cursor repeats. */ +export function repeatTaskAtCursor(view: EditorView, arg = ''): Promise { + return setCursorTaskField(view, 'repeat', arg) +} + +/** `:remind [when|off]`: set when the task under the cursor reminds. */ +export function remindTaskAtCursor(view: EditorView, arg = ''): Promise { + return setCursorTaskField(view, 'remind', arg) +} diff --git a/packages/app-core/src/lib/folder-picker.test.ts b/packages/app-core/src/lib/folder-picker.test.ts new file mode 100644 index 000000000..0cbc83287 --- /dev/null +++ b/packages/app-core/src/lib/folder-picker.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import type { FolderEntry, NoteFolder, VaultSettings } from '@shared/ipc' +import { + filterFolderChoices, + folderNameChoices, + primaryFolderChoices, + stepPickerIndex, + vaultFolderPathChoices, + type FolderChoice +} from './folder-picker' + +// #1033: the Settings fields that name a vault folder list the vault's +// folders, in the form each field stores, and narrow as you type. The New +// note form's Folder field (#826) ranks and steps through rows the same way. + +const entry = (folder: NoteFolder, subpath: string): FolderEntry => ({ + folder, + subpath, + siblingOrder: 0 +}) + +const folders = [ + entry('inbox', ''), + entry('inbox', 'Projects/Alpha'), + entry('inbox', 'Projects'), + entry('inbox', 'Books'), + entry('inbox', 'Reading.base'), + entry('inbox', 'assets'), + entry('inbox', 'Physics/typst'), + entry('quick', 'scratch'), + entry('archive', 'Old/Typst'), + entry('trash', 'Gone') +] + +const inboxVault = { primaryNotesLocation: 'inbox' } as VaultSettings +const rootVault = { primaryNotesLocation: 'root' } as VaultSettings +const values = (choices: readonly FolderChoice[]): string[] => choices.map((c) => c.value) + +describe('vaultFolderPathChoices', () => { + it('lists folders by their path from the vault root, area by area, top level first', () => { + expect(values(vaultFolderPathChoices(folders, inboxVault, { areaRoots: false }))).toEqual([ + 'inbox/assets', + 'inbox/Books', + 'inbox/Projects', + 'inbox/Physics/typst', + 'inbox/Projects/Alpha', + 'quick/scratch', + 'archive/Old/Typst' + ]) + }) + + it('offers the areas themselves when asked, never the Trash or a database', () => { + const paths = values(vaultFolderPathChoices(folders, inboxVault, { areaRoots: true })) + expect(paths.slice(0, 2)).toEqual(['inbox', 'inbox/assets']) + expect(paths).toContain('quick') + expect(paths).toContain('archive') + expect(paths.some((p) => p.startsWith('trash'))).toBe(false) + expect(paths.some((p) => p.includes('.base'))).toBe(false) + }) + + it('drops the inbox prefix in a vault that keeps its notes at the root, and its root', () => { + const paths = values(vaultFolderPathChoices(folders, rootVault, { areaRoots: true })) + expect(paths.slice(0, 3)).toEqual(['assets', 'Books', 'Projects']) + expect(paths).not.toContain('') + }) + + it('follows a remapped system folder', () => { + const remapped = { ...inboxVault, systemFolderPaths: { archive: '99 - Archive' } } as VaultSettings + expect(values(vaultFolderPathChoices(folders, remapped, { areaRoots: false }))).toContain( + '99 - Archive/Old/Typst' + ) + }) +}) + +describe('primaryFolderChoices', () => { + it('lists primary-area folders as the subpath the Specific folder fields store', () => { + // `assets` is an ordinary folder inside `inbox/`; only at the vault root + // does it hold attachments. + expect(values(primaryFolderChoices(folders, inboxVault))).toEqual([ + 'assets', + 'Books', + 'Projects', + 'Physics/typst', + 'Projects/Alpha' + ]) + }) + + it('leaves out a folder whose files would land elsewhere, and databases', () => { + const paths = values(primaryFolderChoices(folders, rootVault)) + expect(paths).not.toContain('assets') + expect(paths).not.toContain('Reading.base') + }) +}) + +describe('folderNameChoices', () => { + it('offers each folder name once, in any case, sorted', () => { + expect(values(folderNameChoices(folders))).toEqual([ + 'Alpha', + 'assets', + 'Books', + 'Projects', + 'scratch', + 'typst' + ]) + }) +}) + +describe('filterFolderChoices', () => { + const choices = vaultFolderPathChoices(folders, inboxVault, { areaRoots: true }) + + it('lists everything for empty text, up to the limit', () => { + expect(filterFolderChoices(choices, '', 3)).toHaveLength(3) + expect(filterFolderChoices(choices, ' ', 100)).toHaveLength(choices.length) + }) + + it('ranks an exact value, then prefixes, then anything containing the text', () => { + expect(values(filterFolderChoices(choices, 'inbox/projects', 100))).toEqual([ + 'inbox/Projects', + 'inbox/Projects/Alpha' + ]) + expect(values(filterFolderChoices(choices, 'typst', 100))).toEqual([ + 'inbox/Physics/typst', + 'archive/Old/Typst' + ]) + expect(values(filterFolderChoices(choices, '/archive/', 100))).toEqual([ + 'archive', + 'archive/Old/Typst' + ]) + expect(filterFolderChoices(choices, 'nothing here', 100)).toEqual([]) + }) +}) + +describe('stepPickerIndex', () => { + it('cycles through the typed value (-1) and the rows, wrapping at both ends', () => { + expect(stepPickerIndex(-1, 1, 3)).toBe(0) + expect(stepPickerIndex(2, 1, 3)).toBe(-1) + expect(stepPickerIndex(-1, -1, 3)).toBe(2) + expect(stepPickerIndex(0, -1, 3)).toBe(-1) + expect(stepPickerIndex(-1, 1, 0)).toBe(-1) + }) +}) diff --git a/packages/app-core/src/lib/folder-picker.ts b/packages/app-core/src/lib/folder-picker.ts new file mode 100644 index 000000000..59fac6e63 --- /dev/null +++ b/packages/app-core/src/lib/folder-picker.ts @@ -0,0 +1,148 @@ +import type { FolderEntry, NoteFolder, VaultSettings } from '@shared/ipc' +import { formDirContaining } from '@shared/databases' +import { normalizeTypstPreambleFolder } from '@shared/typst-preamble-folder' +import { reservedCreateFolderTop, vaultRelativeFolderPath } from './vault-layout' + +/** + * The folder picker: a text field that lists the vault's folders, narrows + * them as you type, and still takes a typed path for a folder that does not + * exist yet. The New note form's Folder field (#826) and the Settings fields + * that name a folder (#1033) share it. This is the pure half: which folders a + * field offers, in the form that field stores, and how typing narrows them. + * Every field keeps its own validation; a row is only a faster way to type. + */ + +export interface FolderChoice { + /** The text the field takes when this row is picked. */ + value: string + /** How the row reads. */ + label: string +} + +/** How many rows the New note form shows. */ +export const DEFAULT_FOLDER_PICKER_ROWS = 12 + +/** + * Narrow the rows to what the field says: an exact value, then values and + * labels that start with the text, then anything containing it. Empty text + * lists everything, in the order the choices came in. + */ +export function filterFolderChoices( + choices: readonly T[], + text: string, + limit: number = DEFAULT_FOLDER_PICKER_ROWS +): T[] { + const query = text.trim().toLowerCase().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') + if (!query) return choices.slice(0, limit) + return choices + .map((choice, index) => { + const value = choice.value.toLowerCase() + const label = choice.label.toLowerCase() + const rank = + value === query + ? 0 + : value.startsWith(query) + ? 1 + : label.startsWith(query) + ? 2 + : value.includes(query) || label.includes(query) + ? 3 + : null + return rank === null ? null : { choice, rank, index } + }) + .filter((entry): entry is { choice: T; rank: number; index: number } => !!entry) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .slice(0, limit) + .map((entry) => entry.choice) +} + +/** + * The highlight after one step of the arrows (or Ctrl+N / Ctrl+P). It cycles + * through the typed value (-1) and rows 0 to n-1, and wraps at both ends, so + * a typed path is always one step from the first and the last row. + */ +export function stepPickerIndex(current: number, delta: number, rows: number): number { + if (rows <= 0) return current + const next = current + delta + if (next < -1) return rows - 1 + if (next >= rows) return -1 + return next +} + +/** Areas a picker offers, in the order the sidebar shows them. Never the Trash. */ +export const PICKER_AREAS: readonly Exclude[] = ['inbox', 'quick', 'archive'] + +/** One area's subfolders, top level first, then by name. Database folders + * (`.base`) are left out: they hold rows, and the sidebar shows them as + * databases, not folders. */ +export function areaSubfolders(folders: readonly FolderEntry[], area: NoteFolder): string[] { + const seen = new Set() + return folders + .filter((entry) => entry.folder === area && entry.subpath && !formDirContaining(entry.subpath)) + .map((entry) => entry.subpath) + .filter((subpath) => !seen.has(subpath) && !!seen.add(subpath)) + .sort((a, b) => { + const depth = a.split('/').length - b.split('/').length + return depth || a.localeCompare(b) + }) +} + +/** + * Folders as their path from the vault root, the form the Task folder lists + * and the template folder store (`inbox/Projects`, or `Projects` in a vault + * that keeps its notes at the root). `areaRoots` also offers the areas + * themselves (`inbox`, `archive`); the root of a vault that keeps its notes at + * the root has no path and is never offered. + */ +export function vaultFolderPathChoices( + folders: readonly FolderEntry[], + settings: VaultSettings | null | undefined, + options: { areaRoots: boolean } +): FolderChoice[] { + const out: FolderChoice[] = [] + const seen = new Set() + const push = (area: NoteFolder, subpath: string): void => { + const path = vaultRelativeFolderPath(area, subpath, settings) + if (!path || seen.has(path)) return + seen.add(path) + out.push({ value: path, label: path }) + } + for (const area of PICKER_AREAS) { + if (options.areaRoots) push(area, '') + for (const subpath of areaSubfolders(folders, area)) push(area, subpath) + } + return out +} + +/** + * Folders inside the primary notes area, the form the `Specific folder` and + * Kanban folder root fields store (`Projects/Alpha`, never `inbox/…`). A folder + * whose top would send new files somewhere else (`assets`, a hidden folder) is + * left out, since picking it could never mean what the row says. + */ +export function primaryFolderChoices( + folders: readonly FolderEntry[], + settings: VaultSettings | null | undefined +): FolderChoice[] { + return areaSubfolders(folders, 'inbox') + .filter((subpath) => !reservedCreateFolderTop(subpath, settings)) + .map((subpath) => ({ value: subpath, label: subpath })) +} + +/** + * Folder names, for a field that stores one name matched at any depth (the + * Typst preamble folder). Each name once, in any case, and only names the + * field would accept. + */ +export function folderNameChoices(folders: readonly FolderEntry[]): FolderChoice[] { + const byKey = new Map() + for (const area of PICKER_AREAS) { + for (const subpath of areaSubfolders(folders, area)) { + const name = normalizeTypstPreambleFolder(subpath.split('/').pop() ?? '') + if (name && !byKey.has(name.toLowerCase())) byKey.set(name.toLowerCase(), name) + } + } + return [...byKey.values()] + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ value: name, label: name })) +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 87cbda780..08cb1c53f 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -136,6 +136,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Run “New Database” from the command palette (or right-click a folder in the sidebar → New database) to create one, or just open an existing `.csv` file from the vault. ZenNotes stores a database as a `.base` folder: `data.csv` (the rows), `schema.json` (field types, select options, saved views), and the record-page notes rows open into. A database opens from its sidebar row like a note: click it, or press Enter or `l` on it in Vim mode (the row’s `m` menu carries Open database too); the chevron still expands the folder to browse its record pages, and the open grid closes like any tab. Edit cells inline in the Table view, group records in a Board by any select field, switch the raw-CSV toggle to see the underlying file, and open a row as a full Markdown page whose frontmatter mirrors the row’s properties (its Open button, Open page in its menu, or `o` in Vim mode). The whole grid is keyboard-driven. See the Database grid shortcuts.' }, + { + title: 'Make a Kanban board for each project', + body: + 'Right-click a folder in the sidebar and choose New Kanban for a board of its own: one per class, project or client, each with its own cards. It is a database (`Untitled Kanban.base`) with a Name and a Status of To do, Doing and Done, and it opens on its Board, so it works on the phones, a self-hosted server, the web app and `zn base` like any database. New Kanban is also in the command palette, `Space k` in Vim mode and `:kanban` on the ex line, which create it in the folder under the sidebar cursor while the sidebar has focus and where new databases go otherwise (Settings → Vault → Default Locations); `:kanban Biology` names the folder. Double-click the title to rename it. Add card at the foot of a column adds a card there: type its title and press Enter, and the field stays open for the next one. Drag a card to another column, or move it from the keyboard (see the Database board shortcuts). Add column makes another status, and a card with no status sits in a No status column that shows only while it holds a card. Open a card as a page for its notes, or switch to the Table to add fields such as a due date. This is separate from the Tasks Kanban board, which shows the `- [ ]` tasks of the whole vault.' + }, { title: 'Set the width of a table column', body: @@ -144,7 +149,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Convert a Markdown table into a database', body: - 'A note often starts with a small pipe table that outgrows inline editing. Put the cursor in it and run “Convert Table to Database…” from the command palette (`:table_to_database` on the ex line, or `Space l d` in Vim mode); in a rendered table, the same entry sits at the bottom of the cell menu (right-click, or `m` in Vim mode). Confirm a name (prefilled from the heading above the table, or the note’s title) and ZenNotes creates a `.base` database where new databases go (Settings → Vault → New Drawings, Databases & Tasks location), then replaces the table in the note with a `[[Name]]` link that opens the grid. Every column becomes a field, typed the way an opened CSV is (checkbox, number, date, text) so the Board and filters work at once; cell text travels verbatim, inline Markdown included, and the CSV layer takes care of commas and quotes. Column widths you dragged in the editor carry over; alignment does not, since a grid has none. The swap is an ordinary edit: `u` (or Undo) brings the table back while the database stays in the vault, and the confirmation toast offers an Open button if you want to jump straight to the grid.' + 'A note often starts with a small pipe table that outgrows inline editing. Put the cursor in it and run “Convert Table to Database…” from the command palette (`:table_to_database` on the ex line, or `Space l d` in Vim mode); in a rendered table, the same entry sits at the bottom of the cell menu (right-click, or `m` in Vim mode). Confirm a name (prefilled from the heading above the table, or the note’s title) and ZenNotes creates a `.base` database where new databases go (Settings → Vault → Default Locations), then replaces the table in the note with a `[[Name]]` link that opens the grid. Every column becomes a field, typed the way an opened CSV is (checkbox, number, date, text) so the Board and filters work at once; cell text travels verbatim, inline Markdown included, and the CSV layer takes care of commas and quotes. Column widths you dragged in the editor carry over; alignment does not, since a grid has none. The swap is an ordinary edit: `u` (or Undo) brings the table back while the database stays in the vault, and the confirmation toast offers an Open button if you want to jump straight to the grid.' }, { title: 'Give a note a cover image', @@ -352,7 +357,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Not every checkbox is a task', body: - 'A reading list is a checklist, not a to-do. Add `tasks: false` (or `off`) to a note’s frontmatter and its checkboxes stay checkboxes: they render and toggle exactly as before, but stop feeding the Tasks list, the boards, the calendars, `zn task list`, and the MCP tools. On a #task note, `tasks: note` keeps the note itself on the board while silencing its internal checklist, so a project note can be one card instead of twenty. To collect tasks from a few folders only, such as a single Tasks folder, right-click each one in the sidebar and choose “Use as Task Folder”: once any Task folder is set, checkboxes everywhere else stay plain checkboxes. To retire a whole folder instead (a media backlog, a reference library), choose “Exclude from Tasks”, which also works inside a Task folder. Both lists live under Settings → Tasks and in the vault’s own settings, so the desktop app, the self-hosted web app, the CLI, and the MCP tools agree. When you do want to see past them, `zn task list --include-excluded` and the MCP `list_tasks` tool’s `includeExcluded` flag list everything.' + 'A reading list is a checklist, not a to-do. Add `tasks: false` (or `off`) to a note’s frontmatter and its checkboxes stay checkboxes: they render and toggle exactly as before, but stop feeding the Tasks list, the boards, the calendars, `zn task list`, and the MCP tools. On a #task note, `tasks: note` keeps the note itself on the board while silencing its internal checklist, so a project note can be one card instead of twenty. To collect tasks from a few folders only, such as a single Tasks folder, right-click each one in the sidebar and choose “Use as Task Folder”: once any Task folder is set, checkboxes everywhere else stay plain checkboxes. To retire a whole folder instead (a media backlog, a reference library), choose “Exclude from Tasks”, which also works inside a Task folder. Both lists live under Settings → Tasks and in the vault’s own settings, so the desktop app, the self-hosted web app, the CLI, and the MCP tools agree. In Settings, the field under each list offers the vault’s folders: type to narrow them and pick one with Enter, or type the path of a folder that does not exist yet. When you do want to see past them, `zn task list --include-excluded` and the MCP `list_tasks` tool’s `includeExcluded` flag list everything.' }, { title: 'Check off a task with ⌘L', @@ -382,7 +387,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The Tasks Kanban board, custom statuses, and any field', body: - 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / No due date / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between No due date and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date (or on No due date to reopen it without one: that drop clears its `due:`, and a daily-note task, whose date is its note’s, cannot take it), and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Finished and cancelled tasks leave the boards, with one exception: a task note whose `status:` names one of your Kanban statuses stays in that column on the Custom status board, so a `done` column holds the task notes you finished, still checked. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open (`Tab` steps onto a card’s subtasks). Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' + 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / No due date / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between No due date and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date (or on No due date to reopen it without one: that drop clears its `due:`, and a daily-note task, whose date is its note’s, cannot take it), and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Finished and cancelled tasks leave the boards, with one exception: a task note whose `status:` names one of your Kanban statuses stays in that column on the Custom status board, so a `done` column holds the task notes you finished, still checked. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open (`Tab` steps onto a card’s subtasks). Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label. For a separate board per project, whose cards are not tasks, use New Kanban (see Make a Kanban board for each project).' }, { title: 'Subtasks on the Kanban board', @@ -399,6 +404,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'A WIP limit caps how many cards a column should hold. Right-click a column header and choose **Set WIP limit…**. The header then reads `2 / 3`; past the limit the column turns red, as in `4 / 3`. From the keyboard, `↑` on a column’s first card reaches its header, where `Shift+F10` opens the menu and `Enter` renames the column. In Vim mode, `W` sets the focused column’s limit, `:wip 3` does too, and a bare `:wip` clears it. Limits are kept per board and column, in config.toml’s `[kanban_wip_limits]` table, such as `"status:in-progress" = 3`. The count ignores the filter and the hidden empty tasks.' }, + { + title: 'Kanban column colors', + body: + 'Give a column a color: right-click its header and choose **Color…**, then pick Gray, Red, Yellow, Green, Teal, Blue or Purple (No color takes it off). The header gets a dot and a colored top edge, and the cards’ `@field:value` chips for that column take the same color. Each color comes from the active theme, so it fits light and dark themes alike. From the keyboard, `Shift+F10` on a column’s header opens its menu; in Vim mode, `C` opens the colors for the focused column, `:color blue` sets one, and a bare `:color` clears it. Colors are kept per board and column, in config.toml’s `[kanban_column_colors]` table, such as `"status:in-progress" = "blue"`.' + }, { title: 'Swimlanes on the Kanban board', body: @@ -427,13 +437,23 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Right-click a task for actions', body: - 'Every task surface has the same right-click menu: the Tasks list, the Kanban board, the Tasks calendar, the calendar side panel, and the Timeline panel. It carries Open note, Mark done, Mark in progress, Cancel, @waiting, Forward to note…, due-date presets (today / tomorrow / next week / clear) with Choose a date… for any other day, priority, and Delete, with the keyboard equivalent shown beside each one so the menu doubles as a reminder of the shortcut. Right-clicking also moves the cursor to that task, so the menu and the keyboard always act on the same row. The menu takes typing as a filter: right-click, type “prio”, and only the priority entries stay. Editing a task inline is offered on the calendar surfaces and the Timeline, which have an edit field; elsewhere “Open note” takes you to the line.' + 'Every task surface has the same right-click menu: the Tasks list, the Kanban board, the Tasks calendar, the calendar side panel, and the Timeline panel. It carries Open note, Mark done, Mark in progress, Cancel, @waiting, Forward to note…, due-date presets (today / tomorrow / next week / clear) with Choose a date… for any other day, Repeat… and Remind… (and Skip this time on a repeating task), priority, and Delete, with the keyboard equivalent shown beside each one so the menu doubles as a reminder of the shortcut. Right-clicking also moves the cursor to that task, so the menu and the keyboard always act on the same row. The menu takes typing as a filter: right-click, type “prio”, and only the priority entries stay. Editing a task inline is offered on the calendar surfaces and the Timeline, which have an edit field; elsewhere “Open note” takes you to the line.' }, { title: 'Mark a task in progress', body: 'A task you have started but not finished can say so: `- [/]`. Write it directly, run “Mark Task In Progress” from the command palette with the cursor on a task, or press `i` on a task in the Tasks list (press `i` again to set it back to open). It renders as a half-filled box in the editor and the reading view, and that box is clickable on both surfaces: a click checks the task off, the same `[/]` to `[x]` the toggle command performs (the forwarded and cancelled markers stay unclickable records). Moving an in-progress card between live Kanban columns keeps the `/` too. Unlike forwarded or cancelled, an in-progress task is still live work: it keeps its place in Today, Upcoming, or No due date, stays on the calendar and the Kanban board, and rolls forward with your unfinished tasks when you roll over a daily note, `/` and all. A whole-note task file uses `status: in-progress` in its frontmatter (`doing`, `started` and `wip` are read the same way).' }, + { + title: 'Repeat a task', + body: + 'Add `@repeat:` to a task and checking it off schedules the next one: `- [ ] Water the plants @repeat:weekly due:2026-11-01`. The rules are `daily`, `weekdays`, `weekly`, `monthly` and `yearly`; `2d`, `3w`, `6m` or `1y` for every so many days, weeks, months or years; weekdays like `mon/thu`; and `after-3d` (any interval) to count from the day you finish instead of the due date. Check it off with `⌘L`, a click on its checkbox, `x` in the Tasks views or a drop into Done, and the next occurrence is written right above it with the next date in `due:`, while the finished line stays below as the record, without `@repeat` (so unchecking it never makes a second copy). Its subtasks repeat too, every checkbox open again, and the record cancels (`[-]`) the ones you left open, so only the fresh copies stay on your lists; subtasks you select along with the task (`Vjj` then `⌘L`) are checked off instead. The next date counts from the due date (or, without one, the daily note’s date, then today) and is never today or earlier: a daily task done three days late comes back tomorrow, not as three overdue copies. `monthly` from the 31st lands on the last day of shorter months. Press `r` in the Tasks views (or Repeat… in the right-click menu, or `:repeat weekly` in a note) to set or stop it; Skip this time in the menu schedules the next one and records this one as cancelled. In the Tasks views a repeating task shows a repeat icon beside its date. Typing an `x` into the brackets by hand is just text and does not repeat. Whole-note task files do not repeat yet.' + }, + { + title: 'Remind me before a task is due', + body: + 'Add `@remind:` to a task with a due date and ZenNotes shows a notification: `@remind:9am` (or `930am`, `1730`, `17h30`) at that time on the due date, `@remind:1d` the day before at your reminder time (09:00 unless changed in Settings → Tasks), `@remind:2d-6pm` two days before at 6 PM. A daily note’s tasks remind on the note’s date. Click the notification to open the task. Press `R` in the Tasks views (or Remind… in the right-click menu, or `:remind 9am` in a note) to set or remove it; the Tasks views show a bell and the time. Reminders fire from the desktop app while it runs, the window hidden in the tray included. ZenNotes quit misses them: the next launch shows what came due in the last 24 hours once, as one notification. Turn reminders off under Settings → Tasks → Task reminders. A recurring task carries its reminder to each new occurrence.' + }, { title: 'Subtask progress on the parent', body: @@ -689,6 +709,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space p', action: 'Note outline', detail: 'Jump to any heading in the active note via a searchable overlay.' }, { keys: 'Space u', action: 'Unresolved links', detail: 'Open the Unresolved Links view: every link in the vault to a note that does not exist yet, with the notes and lines that link to it. Enter on a name creates the note, Enter on a line opens the note at the link, and K writes the missing note in the link popover. Also `:unresolved`.' }, { keys: 'Space G', action: 'Folder gallery', detail: 'Open the Gallery of the folder under the sidebar cursor (while the sidebar has focus) or of the open note’s folder: its notes as cards led by their covers. h / j / k / l move, Enter opens, / filters by title. A capital G, since `Space g` is the Atlas.' }, + { keys: 'Space k', action: 'New Kanban', detail: 'Create a Kanban board (To do, Doing, Done) in the folder under the sidebar cursor while the sidebar has focus, otherwise where new databases go, and open it. Also New Kanban in the palette and `:kanban`.' }, { keys: 'Space v', action: 'Switch vault', detail: 'Open the command palette directly to the local vault switcher.' }, { keys: 'Space a', action: 'Open workflows', detail: 'Open the Workflows view, where saved pipelines over your notes are built and run. Workflows are off by default; turn them on under Settings → Workflows first.' }, { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. When the open note is one of the waiting files, the queue starts on it. A waiting note also says so across its top, in Edit and Preview alike: sync is paused for it until you choose what to keep, and its Review button opens the queue on it. Choosing the other device’s version puts the file where that device left it; when this device already has a different file there, that file moves beside it under the next free name, such as Untitled 2.md. A notification with Review appears once when a new conflict arrives. When the Cloud vault this vault syncs with is deleted, on this device, another device or the website, the status bar says Cloud vault deleted and the binding opens Settings → Cloud instead, where This vault says what happened until you choose a cloud vault, start a new one, or dismiss the notice. If another device moved it into an end-to-end encrypted vault, the status bar says Cloud vault now encrypted instead, and This vault asks you to unlock this device if needed, then choose the encrypted vault. The binding and the command palette entry appear only while something is waiting, and open the same place as Review in the status bar.' }, @@ -814,6 +835,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: '>', action: 'Forward task', detail: 'Tasks list only: forward the selected task to another note. Opens a note picker that filters as you type and preselects the first match, so Enter forwards without reaching for an arrow key. The original becomes a forwarded record (`[>]`) linking to the target, and a fresh copy is added there, backlinked home, subtasks and all. Forwarded tasks live under a “Forwarded” group.' }, { keys: 'i', action: 'Mark task in progress', detail: 'Tasks list only: mark the selected task as started (`- [/]`), or set it back to open. In-progress tasks keep their group and their day on the calendar, so the row keeps its place.' }, { keys: 'c', action: 'Cancel task', detail: 'Tasks list only: mark the selected task as intentionally abandoned (`- [-]`), or un-cancel it. Cancelled tasks live under a “Cancelled” group, out of Today and Done.' }, + { keys: 'r / R', action: 'Repeat or remind', detail: 'Tasks list, board, and calendar: `r` sets how the selected task repeats (`@repeat:`), `R` when it reminds (`@remind:`). Each opens a picker of common choices; type any other rule, like `2w` or `2d-6pm`. Also in the right-click menu.' }, { keys: '@', action: 'Choose due date', detail: 'Tasks list, board, and calendar: give the selected task any due date from the month calendar that typing `@date` opens in a note, the same as Choose a date… in its right-click menu. It starts on the task’s due date (today without one); arrows move, Enter sets the day, or type a date.' }, { keys: 'Tab / Shift+Tab', action: 'Step through a card’s subtasks', detail: 'Kanban board: step onto a card’s subtasks and back. Space (or x), Enter, i, c and @ act on that subtask; Esc returns to the card. Works with Vim mode on or off.' }, { keys: 'K / J', action: 'Move task up / down', detail: 'Tasks list: reorder the selected task within its group. Works with Vim mode on or off, in Note order (see `s`). On a Kanban board with lanes, in Vim mode: move the card a lane up or down, writing that lane\'s field.' }, @@ -826,6 +848,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'v', action: 'View menu', detail: 'Tasks view: open the View menu (Hide empty tasks, and the board\'s Lanes).' }, { keys: '?', action: 'Keys', detail: 'Tasks view: list the current view’s keys. Esc closes it.' }, { keys: 'W', action: 'Set a WIP limit', detail: 'Tasks board only: set the focused column’s WIP limit. Same as `:wip `.' }, + { keys: 'C', action: 'Color a column', detail: 'Tasks board only: pick the focused column’s color. Same as Color… in the column menu, or `:color `.' }, { keys: 'S', action: 'Pick the board\'s lanes', detail: 'Tasks board only: step the lanes through None, Priority, Folder, Tag and your @fields. Same as Lanes in the View menu, or `:lanes `.' }, { keys: 'za / zM / zR', action: 'Collapse lanes', detail: 'Tasks board with lanes: `za` collapses or expands the lane under the cursor, `zM` collapses every lane, `zR` expands them all. Enter on a lane\'s header does what `za` does.' }, { keys: ':', action: 'Open local ex prompt', detail: 'Run the view-specific command line inside Tasks or Tags.' }, @@ -907,6 +930,18 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Ctrl-o', action: 'Jump back to the grid', detail: 'Vim mode. From a record page, return to the database grid, whether you opened it as a .csv file or via New Database.' }, { keys: 'Ctrl-w k / j / h / l', action: 'Move to tabs or panes', detail: 'Vim mode. Move between the grid, the tab strip, and split panes, exactly like from the editor.' } ] + }, + { + id: 'database-board', + title: 'Database board (Board view)', + description: 'Keys for a database Board, a Kanban made with New Kanban included, while the board has focus. They match the Tasks Kanban board. The letters belong to Vim mode; with Vim mode off the arrows, Enter and Shift+F10 (or the Context Menu key) do the work, and the card menu moves cards.', + items: [ + { keys: '← / → / ↑ / ↓', action: 'Move the cursor', detail: 'In either mode; h / l / k / j in Vim mode. Down from a column’s last card reaches its Add card.' }, + { keys: 'Enter', action: 'Open the card’s page', detail: 'In either mode; o in Vim mode. On Add card, or in an empty column, it opens the field for a new card.' }, + { keys: 'a', action: 'Add a card', detail: 'Vim mode. Opens Add card in the cursor’s column. Enter adds the card and keeps the field open for the next; Escape closes it.' }, + { keys: 'H / L', action: 'Move the card a column left / right', detail: 'Vim mode. Sets the card’s status; the cursor follows it. In either mode, drag the card or use Move to in its menu.' }, + { keys: 'Shift+F10', action: 'Open the card menu', detail: 'In either mode, or the Context Menu key; m in Vim mode. Open page, Move to each other column, and Delete card (which asks first).' } + ] } ] @@ -961,6 +996,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Set the focused Kanban column’s WIP limit', detail: 'On the board, `:wip 3` caps the focused column at three cards; past that it turns red. A bare `:wip` clears it. Same as Set WIP limit… in the column menu, or `W`.' }, + { + command: ':color ', + summary: 'Color the focused Kanban column', + detail: 'On the board, `:color blue` gives the focused column a blue dot and top edge; gray, red, yellow, green, teal and purple work too. A bare `:color` clears it. Same as Color… in the column menu, or `C`.' + }, { command: ':lanes ', summary: 'Split the Kanban board into lanes', @@ -1136,6 +1176,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Open a folder as a Gallery', detail: 'Opens the Gallery of the open note’s folder: its notes as cards led by their covers. With a folder, opens that one instead, given as a path in the vault (`:gallery inbox/Projects`), a folder in your notes (`:gallery Projects/Website`) or a system folder (`:gallery archive`). `:gal` for short; `Space G` and Open Folder Gallery in the palette do the same.' }, + { + command: ':kanban [folder]', + summary: 'Create a Kanban board', + detail: 'Creates a Kanban board, a database with To do, Doing and Done columns that opens on its Board, where `Space k` would: the folder under the sidebar cursor while the sidebar has focus, otherwise where new databases go. With a folder (`:kanban Biology`), creates it there. `:kanb` for short.' + }, { command: ':unresolved', summary: 'Open the Unresolved Links view', @@ -1151,6 +1196,16 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Open a line, continuing the list', detail: 'On a list item, `o` (below) and `O` (above) carry the marker forward like pressing Enter: bullets repeat, numbered lists advance and renumber, checkboxes start a fresh unchecked box, and indentation is kept. On a non-list line they open a plain new line as usual.' }, + { + command: ':repeat [rule|off]', + summary: 'Repeat the task under the cursor', + detail: 'Writes `@repeat:` on the cursor’s task line: `:repeat weekly`, `:repeat 2w`, `:repeat mon/thu`. `:repeat off` stops it, and a bare `:repeat` opens the picker. Also “Repeat Task…” in the command palette.' + }, + { + command: ':remind [when|off]', + summary: 'Remind about the task under the cursor', + detail: 'Writes `@remind:` on the cursor’s task line: `:remind 9am`, `:remind 1d`, `:remind 2d-6pm`. `:remind off` removes it, and a bare `:remind` opens the picker. Also “Remind Task…” in the command palette.' + }, { command: 'Mod+L', summary: 'Check off the task under the cursor', @@ -1379,11 +1434,12 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Vault location', detail: 'Reveal or change the root folder ZenNotes treats as the active vault.' }, { label: 'Vault name', detail: 'What the sidebar header, the vault switcher and the title bar call this vault; the folder keeps its own name on disk. Leave it empty to use the folder name. Saved in the vault’s `.zennotes/vault.json`, so it follows the folder wherever it is opened. Also “Rename Vault…” in the command palette (`:vault_rename`) and the sidebar’s vault menu. Local vaults only.' }, { label: 'Primary notes location', detail: 'Treat `inbox/` as the main notes area, or use the vault root directly for an Obsidian-style flat vault.' }, - { label: 'New Drawings, Databases & Tasks location', detail: 'Choose where new Excalidraw drawings, databases, and task files are created so they do not pile up in the vault root: the primary notes location (the default), the folder of the note you are currently viewing, or a specific folder you name (e.g. `assets/drawings` or `Tasks`). A specific folder lives inside the primary notes area, so `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault; the folder field shows the exact destination. Search Settings for "tasks folder" or "drawings folder" to jump there. Only new files are affected; existing ones stay put.' }, + { label: 'Default Locations', detail: 'Choose where new Excalidraw drawings, databases (Kanbans included), and task files are created so they do not pile up in the vault root: the primary notes location (the default), the folder of the note you are currently viewing, or a specific folder you name (e.g. `Drawings` or `Tasks`). A specific folder lives inside the primary notes area, so `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault; the folder field lists the folders there and shows the exact destination. In a root vault, a folder under `assets/` (or another attachments folder, or a hidden one) cannot hold notes, so new files go to the vault root instead and the field says why. New task files come from `+ New task`, the `a` key in the Tasks view, `:newtask` and the command palette; `New Task in Folder…` and `:newtask ` still pick a folder per task. Search Settings for "tasks folder" or "drawings folder" to jump there. Only new files are affected; existing ones stay put.' }, { label: 'Shares location (macOS)', detail: 'Where a page or text shared from Safari and other apps with Share → ZenNotes is filed in this vault: Quick Notes (the default), the primary notes location, or a specific folder you name inside it. Saved in the vault’s `.zennotes/vault.json`. On a self-hosted server it needs server 2.79 or later; an older server keeps Quick Notes.' }, { label: 'Daily notes', detail: "Enable a daily-notes workflow, choose a directory pattern, naming pattern, locale, and template so each day’s note starts in the right place. Supported tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`; quote literal words like `'Daily Notes'/yyyy/MM-MMM`. Open today’s note with `Space d`, `:daily`, or the command palette. For another day, past or future, double-click it in the calendar panel, or follow a link like `[[2026-11-03]]` to a day that has no note yet: both create that day’s note in the right place, from its template. Hover a day in the calendar panel to read its note, drawn the way it reads, without opening it. Two task options live here too: “Tasks are due on the note’s date” makes tasks in a daily note show on the calendar for that day (on by default), and “Roll over unfinished tasks to today” moves every unchecked task from past daily notes into today when you open it (off by default; also runnable from the command palette)." }, { label: 'Weekly notes', detail: "Enable weekly notes with a directory pattern, naming pattern, locale, and template. Weekly patterns support the same tokens as daily notes plus ISO week `w` and `ww`; the default title pattern is `yyyy-'W'ww`. Open this week’s note with `Space w`, `:weekly`, or the command palette." }, { label: 'Monthly notes', detail: 'Enable monthly notes with a directory pattern, naming pattern, locale, and template. It creates one note per calendar month, handy for monthly reviews and reflections. The default title pattern is `yyyy-MM` (e.g. `2026-07`). Open this month’s note with `Space m`, `:monthly`, or the command palette. Each notes section in Settings now collapses its fields when its toggle is off.' }, + { label: 'Folder fields', detail: 'A Settings field that names a folder (a specific folder for new files, Task folders, the template folder, the Kanban folder root, the Typst preamble folder) lists the vault’s folders when it has focus. Typing narrows the list and highlights the first match; the arrows or `Ctrl+N` / `Ctrl+P` move, Enter picks, and Escape closes the list. A path that matches nothing is kept as typed, so a folder can be named before it exists.' }, { label: 'Folder Paths', detail: 'Point any of the four system folders (Inbox, Quick Notes, Archive, Trash) at a directory of your choosing, labels included; stored as `systemFolderPaths` in `vault.json` so the vault stays portable. Relabeling without moving anything on disk works too.' }, { label: 'Folder icons', detail: 'Right-click a folder in the sidebar and choose a custom icon that follows the current theme colors.' }, { label: 'Saved Remote Workspaces', detail: 'Save multiple remote servers or vaults, reconnect from Settings or the command palette, and edit or remove them later. When connected remotely, Settings also exposes Change Remote Vault…, Return to Local Vault, and Open Local Vault….' } @@ -1392,7 +1448,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { title: 'Templates', items: [ - { label: 'Template folder', detail: 'Notes in this folder are templates, kept out of Tasks, Tags and search. Saved in the vault.' }, + { label: 'Template folder', detail: 'Notes in this folder are templates, kept out of Tasks, Tags and search. Pick it from the vault’s folders, or clear the field for none. Saved in the vault.' }, { label: 'Template library', detail: 'Browse every template, built-in and custom. Built-ins cover engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do).' }, { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, `{{cursor}}`, and the live `{{modified_date}}` / `{{modified_time}}` / `{{modified_datetime}}` (rendered from the note’s last-saved time rather than substituted). It is saved as a `.md` file in `.zennotes/templates/`.' }, { label: 'Edit or reset built-ins', detail: 'Press Edit on a built-in to fork an editable copy that shadows the original everywhere; Reset removes the copy and restores the built-in. Custom templates can be edited or deleted directly.' }, @@ -1414,7 +1470,10 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ title: 'Tasks', items: [ { label: 'Show tasks from archived notes', detail: 'Off by default: archiving a note retires its tasks from the Tasks list, calendar, and Kanban. Turn this on to keep them visible everywhere, Archive column included. The raw key is `show_archived_tasks` in `config.toml`.' }, + { label: 'Task reminders', detail: 'Desktop only, on by default: a task with `@remind:` shows a notification at its time. The phone and web apps leave this setting out, since reminders fire from the desktop app. Config key `task_reminders` under `[view]`.' }, + { label: 'Reminder time', detail: 'Desktop only. When a reminder that names a day but no time fires, such as `@remind:1d`. 09:00 by default; config key `task_reminder_time` under `[view]`.' }, { label: 'Hide empty tasks', detail: 'On by default: bare `- [ ]` tasks stay out of the Tasks views, the Calendar and Timeline panels, and the home view. Same as the View menu; config key `hide_empty_tasks`.' }, + { label: 'Task folders', detail: 'Once any Task folder is set, only the notes inside Task folders feed Tasks; the folders excluded from Tasks never do. Add a folder by picking it from the list under each field or typing its path from the vault root, or from the folder’s sidebar menu. Saved in the vault.' }, { label: 'Kanban lanes', detail: 'Splits the Kanban board into rows by Priority, Folder, or Tag; an `@field` picked on the board shows here too. Same as Lanes in the View menu; config key `kanban_lanes`.' }, { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' }, { label: 'Tasks list order', detail: 'Note order keeps each group of the Tasks list in its notes’ line order, which Shift+J/K and dragging rearrange; Priority puts high priority first, then medium, low, and none, ties by due date. The list’s Order menu sets the same thing, and the raw key is `tasks_list_order` (`note` or `priority`) under `[view]` in `config.toml`.' }, @@ -1442,7 +1501,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Count this copy anonymously', detail: 'Once when ZenNotes is installed and once per update, the desktop app sends zennotes.org the version, operating system, architecture and install type, so we know how many copies are in use. It carries no identifier and nothing about your notes, vaults or account, and only daily totals are kept. On by default; turn it off here to send nothing. Set in config.toml as `[privacy] usage_count`.' }, { label: 'Updates and releases', detail: 'Check for updates, download a newer build, install and relaunch, or jump straight to the latest GitHub release from inside the app. AUR and tarball installs get the check and the notice only; the package manager does the install.' }, { label: 'Website, community, and issue links', detail: 'The app now exposes direct links to the ZenNotes website, Discord, GitHub repository, and issue tracker so support paths stay discoverable.' }, - { label: 'Configuration file', detail: 'Your preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml` so you can sync them across machines with git, stow, or chezmoi. It lives at `$XDG_CONFIG_HOME/zennotes/config.toml` (`~/.config/zennotes/config.toml` on macOS and Linux, `%APPDATA%\\zennotes\\config.toml` on Windows), or wherever `$ZENNOTES_CONFIG_DIR` points. The file is self-documenting: every available setting is listed with its allowed values, and every keymap action is listed with its default binding (commented out: uncomment a line and edit it to remap, or set it to `""` to remove the key entirely), and every command palette command is listed under `[keymaps.commands]` (`"note.trash" = "Mod+Backspace"`, or `"note.archive" = "Leader x a"` for a leader sequence; a command id written straight under `[keymaps]` is read as a command key too and moved to that table on the next save), so you can discover and change anything without opening the app. That list reads the same on every OS: where macOS has its own default, the line notes it, as in `# "tabs.select1" = "Alt+1" # Go to tab 1 (macOS: "Ctrl+1")`, so a file synced between a Mac and a Linux or Windows machine changes only when you change a setting. Settings → About has Reveal and Copy-path buttons. Existing setups are written out automatically the first time you launch this version, and edits to the file, by hand or via a synced dotfile, apply live without a restart. Machine-specific layout (window size, pane widths, collapsed folders) stays local so the file does not churn.' }, + { label: 'Configuration file', detail: 'Your preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml` so you can sync them across machines with git, stow, or chezmoi. It lives at `$XDG_CONFIG_HOME/zennotes/config.toml` (`~/.config/zennotes/config.toml` on macOS and Linux, `%APPDATA%\\zennotes\\config.toml` on Windows), or wherever `$ZENNOTES_CONFIG_DIR` points. The file is self-documenting: every available setting is listed with its allowed values, and every keymap action is listed with its default binding (commented out: uncomment a line and edit it to remap, or set it to `""` to remove the key entirely), and every command palette command is listed under `[keymaps.commands]` (`"note.trash" = "Mod+Backspace"`, or `"note.archive" = "Leader x a"` for a leader sequence; a command id written straight under `[keymaps]` is read as a command key too and moved to that table on the next save), so you can discover and change anything without opening the app. That list reads the same on every OS: where macOS has its own default, the line notes it, as in `# "tabs.select1" = "Alt+1" # Go to tab 1 (macOS: "Ctrl+1")`, so a file synced between a Mac and a Linux or Windows machine changes only when you change a setting. Settings → About has Reveal and Copy-path buttons. Existing setups are written out automatically the first time you launch this version, and edits to the file, by hand or via a synced dotfile, apply live without a restart. Tables and keys only zn uses, like `[terminal]`, stay in the file when the app saves it. Machine-specific layout (window size, pane widths, collapsed folders) stays local so the file does not churn.' }, { label: 'Lumary Labs', detail: 'The About section links to Lumary Labs at lumarylabs.com so company details stay easy to find from inside the app.' } ] } @@ -1465,9 +1524,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.13.1 in desktop 2.80.0', + title: 'CLI 0.14.0 in desktop 2.81.0', body: - 'On macOS and Linux, desktop 2.80.0 bundles CLI 0.13.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. 0.9.2 lets a second `$` on a wrapped line reach the end of the line and makes three backticks a code fence, 0.10.0 adds `zn base pages`, which gives every database record its record page so each can be linked, and 0.11.0 gives a task without a due date its own No due date group and Status board column, as this app does (#990), and orders the task list by priority with `O` or `:order priority` (#989). 0.12.0 can draw display math and TikZ in the TUI preview with `[terminal] math = "latex"`, using TeX installed on your computer (zn does not include TeX; `zn doctor` prints the install commands), 0.12.1 fits pictures in the preview to the pane as it grows or shrinks, 0.12.2 says which TeX tool is missing and how to install it, and 0.13.0 lets `zn mcp` share only part of a vault (`--allow-path`, `--deny-path`, `--read-only`, `--access`; see Share part of a vault with an assistant), keeps assistants out of hidden and system folders, and puts a row `zn base add` makes into the picked rows of the view the database opens on; 0.13.1 leaves the template folder out of tasks, search and tags, as the app does. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.81.0 bundles CLI 0.14.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. 0.9.2 lets a second `$` on a wrapped line reach the end of the line and makes three backticks a code fence, 0.10.0 adds `zn base pages`, which gives every database record its record page so each can be linked, and 0.11.0 gives a task without a due date its own No due date group and Status board column, as this app does (#990), and orders the task list by priority with `O` or `:order priority` (#989). 0.12.0 can draw display math and TikZ in the TUI preview with `[terminal] math = "latex"`, using TeX installed on your computer (zn does not include TeX; `zn doctor` prints the install commands), 0.12.1 fits pictures in the preview to the pane as it grows or shrinks, 0.12.2 says which TeX tool is missing and how to install it, and 0.13.0 lets `zn mcp` share only part of a vault (`--allow-path`, `--deny-path`, `--read-only`, `--access`; see Share part of a vault with an assistant), keeps assistants out of hidden and system folders, and puts a row `zn base add` makes into the picked rows of the view the database opens on; 0.13.1 leaves the template folder out of tasks, search and tags, as the app does, and 0.14.0 adds month, week and day views to the TUI’s Tasks calendar, a bar cursor while you type, a note’s own view mode from its frontmatter (`[terminal] view_mode_field` and `[terminal.view_mode_rules]`), and opens every new note in the editor. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', diff --git a/packages/app-core/src/lib/kanban-column-colors.test.ts b/packages/app-core/src/lib/kanban-column-colors.test.ts new file mode 100644 index 000000000..8d3000d52 --- /dev/null +++ b/packages/app-core/src/lib/kanban-column-colors.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + KANBAN_COLUMN_COLORS, + kanbanColumnColorCss, + kanbanColumnColorLabel, + kanbanColumnColorVar, + parseKanbanColumnColor +} from './kanban-column-colors' + +describe('kanban column colours (#1037)', () => { + it('reads the palette names however they are typed', () => { + expect(parseKanbanColumnColor('blue')).toBe('blue') + expect(parseKanbanColumnColor(' Blue ')).toBe('blue') + expect(parseKanbanColumnColor('PURPLE')).toBe('purple') + // The other spelling, and the theme token's own name for teal. + expect(parseKanbanColumnColor('grey')).toBe('gray') + expect(parseKanbanColumnColor('aqua')).toBe('teal') + }) + + it('refuses what the palette does not have, a hex included', () => { + for (const raw of ['', 'orange', '#ff0000', 'rgb(0 0 0)', 'accent', 3, null, undefined, {}]) { + expect(parseKanbanColumnColor(raw)).toBeNull() + } + }) + + it('draws every colour from a theme token, never a fixed hex', () => { + for (const color of KANBAN_COLUMN_COLORS) { + expect(color.token).toMatch(/^--z-[a-z0-9-]+$/) + expect(kanbanColumnColorVar(color.id)).toBe(`var(${color.token})`) + expect(kanbanColumnColorCss(color.id)).toBe(`rgb(var(${color.token}))`) + } + expect(kanbanColumnColorCss('teal')).toBe('rgb(var(--z-aqua))') + expect(kanbanColumnColorLabel('gray')).toBe('Gray') + }) + + it('keeps to the hue tokens every bundled theme defines', async () => { + const { readFileSync } = await import('node:fs') + const css = readFileSync(new URL('../styles/index.css', import.meta.url), 'utf8') + // Each theme block that sets one hue sets them all, so no column colour + // falls back to another theme's shade. + const blocks = css.split(/\n(?=:root)/).filter((block) => block.includes('--z-red:')) + expect(blocks.length).toBeGreaterThan(30) + for (const block of blocks) { + for (const color of KANBAN_COLUMN_COLORS) expect(block).toContain(`${color.token}:`) + } + }) +}) diff --git a/packages/app-core/src/lib/kanban-column-colors.ts b/packages/app-core/src/lib/kanban-column-colors.ts new file mode 100644 index 000000000..adcbbf9c3 --- /dev/null +++ b/packages/app-core/src/lib/kanban-column-colors.ts @@ -0,0 +1,45 @@ +/** + * The colours a Kanban column can carry (#1037). A short palette on the + * theme's own hue tokens rather than fixed hexes, so each bundled theme (and + * a custom one) draws Blue in its own blue, light and dark alike. Stored by + * name, which is also what a person types in config.toml. + */ +export const KANBAN_COLUMN_COLORS = [ + { id: 'gray', label: 'Gray', token: '--z-grey-1' }, + { id: 'red', label: 'Red', token: '--z-red' }, + { id: 'yellow', label: 'Yellow', token: '--z-yellow' }, + { id: 'green', label: 'Green', token: '--z-green' }, + { id: 'teal', label: 'Teal', token: '--z-aqua' }, + { id: 'blue', label: 'Blue', token: '--z-blue' }, + { id: 'purple', label: 'Purple', token: '--z-purple' } +] as const + +export type KanbanColumnColor = (typeof KANBAN_COLUMN_COLORS)[number]['id'] + +// The other spelling of a name, and the theme token's own name for teal. +const ALIASES: Record = { grey: 'gray', aqua: 'teal' } + +/** A colour name as stored or typed (`Blue`, ` grey `), or null when it names + * none of the palette. */ +export function parseKanbanColumnColor(raw: unknown): KanbanColumnColor | null { + if (typeof raw !== 'string') return null + const name = raw.trim().toLowerCase() + const known = KANBAN_COLUMN_COLORS.find((color) => color.id === name) + return known ? known.id : (ALIASES[name] ?? null) +} + +export function kanbanColumnColorLabel(color: KanbanColumnColor): string { + return KANBAN_COLUMN_COLORS.find((entry) => entry.id === color)?.label ?? color +} + +/** The theme token behind a palette entry, as `var(--z-blue)`: an RGB + * triplet, for `rgb(...)` with or without an alpha. */ +export function kanbanColumnColorVar(color: KanbanColumnColor): string { + const token = KANBAN_COLUMN_COLORS.find((entry) => entry.id === color)?.token ?? '--z-grey-1' + return `var(${token})` +} + +/** The CSS colour of a palette entry, from the active theme's token. */ +export function kanbanColumnColorCss(color: KanbanColumnColor): string { + return `rgb(${kanbanColumnColorVar(color)})` +} diff --git a/packages/app-core/src/lib/kanban-create.test.ts b/packages/app-core/src/lib/kanban-create.test.ts new file mode 100644 index 000000000..aad9ae51b --- /dev/null +++ b/packages/app-core/src/lib/kanban-create.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({}), + platformSync: () => 'darwin', + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false) + } + }) +} + +function note(path: string): NoteMeta { + return { + path, + title: path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, ''), + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + } +} + +async function load() { + vi.resetModules() + localStorage.clear() + installZen() + const { useStore } = await import('../store') + const kanban = await import('./kanban-create') + const createKanban = vi.fn().mockResolvedValue(undefined) + const newKanban = vi.fn().mockResolvedValue(undefined) + useStore.setState({ createKanban, newKanban }) + return { useStore, createKanban, newKanban, ...kanban } +} + +/** A sidebar row the way Sidebar draws one, numbered for its Vim cursor. */ +function sidebarRow(idx: number, attrs: Record): void { + const row = document.createElement('div') + row.setAttribute('data-sidebar-idx', String(idx)) + for (const [name, value] of Object.entries(attrs)) row.setAttribute(name, value) + document.body.appendChild(row) +} + +beforeEach(() => { + document.body.innerHTML = '' +}) + +describe('canHoldKanban', () => { + it('takes notes-area folders, never a system folder or a database folder', async () => { + const s = await load() + expect(s.canHoldKanban({ folder: 'inbox', subpath: '' })).toBe(true) + expect(s.canHoldKanban({ folder: 'inbox', subpath: 'School/Biology' })).toBe(true) + expect(s.canHoldKanban({ folder: 'inbox', subpath: 'School/Grades.base' })).toBe(false) + expect(s.canHoldKanban({ folder: 'quick', subpath: '' })).toBe(false) + expect(s.canHoldKanban({ folder: 'archive', subpath: '2025' })).toBe(false) + expect(s.canHoldKanban({ folder: 'trash', subpath: '' })).toBe(false) + }) +}) + +describe('newContextKanban', () => { + it('creates in the folder row under the sidebar cursor while the sidebar has focus', async () => { + const s = await load() + sidebarRow(0, { 'data-sidebar-type': 'folder', 'data-sidebar-folder': 'inbox', 'data-sidebar-subpath': 'Biology' }) + s.useStore.setState({ focusedPanel: 'sidebar', sidebarOpen: true, sidebarCursorIndex: 0 }) + await s.newContextKanban() + expect(s.createKanban).toHaveBeenCalledWith('inbox', 'Biology') + expect(s.newKanban).not.toHaveBeenCalled() + }) + + it('creates in a note row’s folder, but not inside a database', async () => { + const s = await load() + sidebarRow(0, { 'data-sidebar-type': 'note', 'data-sidebar-path': 'inbox/History/Essay.md' }) + sidebarRow(1, { 'data-sidebar-type': 'note', 'data-sidebar-path': 'inbox/History/Grades.base/Alice.md' }) + s.useStore.setState({ + notes: [note('inbox/History/Essay.md'), note('inbox/History/Grades.base/Alice.md')], + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 0 + }) + await s.newContextKanban() + expect(s.createKanban).toHaveBeenCalledWith('inbox', 'History') + s.useStore.setState({ sidebarCursorIndex: 1 }) + await s.newContextKanban() + expect(s.newKanban).toHaveBeenCalledTimes(1) + }) + + it('goes where new databases go when the sidebar does not have focus', async () => { + const s = await load() + sidebarRow(0, { 'data-sidebar-type': 'folder', 'data-sidebar-folder': 'inbox', 'data-sidebar-subpath': 'Biology' }) + s.useStore.setState({ focusedPanel: 'editor', sidebarCursorIndex: 0 }) + await s.newContextKanban() + expect(s.newKanban).toHaveBeenCalledTimes(1) + expect(s.createKanban).not.toHaveBeenCalled() + }) +}) + +describe('newKanbanFromArgument', () => { + it('creates in the folder an argument names, and says why when it cannot', async () => { + const s = await load() + s.useStore.setState({ folders: [{ folder: 'inbox', subpath: 'Biology', siblingOrder: 0 }] }) + await s.newKanbanFromArgument('biology') + expect(s.createKanban).toHaveBeenCalledWith('inbox', 'Biology') + const { useToastStore } = await import('./toast') + await s.newKanbanFromArgument('Nowhere') + expect(useToastStore.getState().toasts.at(-1)?.message).toBe('No folder called "Nowhere" in this vault') + await s.newKanbanFromArgument('trash') + expect(useToastStore.getState().toasts.at(-1)?.message).toBe('A Kanban can\'t go in "trash"') + expect(s.createKanban).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/lib/kanban-create.ts b/packages/app-core/src/lib/kanban-create.ts new file mode 100644 index 000000000..15bfe426c --- /dev/null +++ b/packages/app-core/src/lib/kanban-create.ts @@ -0,0 +1,78 @@ +/** + * Where New Kanban puts the board when it is not run from a folder's own + * menu (#1038): the palette, `Space k`, and `:kanban`. + * + * The sidebar row under the cursor wins while the sidebar holds the keyboard + * (a folder row is that folder, a note row its note's folder), as it does + * for the Gallery. Otherwise the board goes where new databases go + * (Settings → Vault → Default Locations). Only a folder in the + * notes area takes one: a board in Quick Notes, the Archive or the Trash, or + * inside another database's `.base` folder, would be filed somewhere nobody + * looks for it. + */ +import { isFormDirName } from '@shared/databases' +import { useStore } from '../store' +import { + galleryFolderOfNote, + resolveGalleryFolderArgument, + type GalleryFolder +} from './folder-gallery' +import { sidebarCursorRow, sidebarHoldsFocus } from './sidebar-note-actions' +import { resolveSystemFolderLabels } from './system-folder-labels' +import { useToastStore } from './toast' + +/** True when a Kanban created in `target` lands in the notes area, outside + * any database folder. */ +export function canHoldKanban(target: GalleryFolder): boolean { + if (target.folder !== 'inbox') return false + return !target.subpath.split('/').some((segment) => isFormDirName(segment)) +} + +/** The folder under the sidebar cursor, while the sidebar has the keyboard + * and that folder can hold a Kanban; null means "where databases go". */ +export function contextKanbanFolder(): GalleryFolder | null { + const state = useStore.getState() + if (typeof document === 'undefined' || !sidebarHoldsFocus(state)) return null + const row = sidebarCursorRow(state.sidebarCursorIndex) + let target: GalleryFolder | null = null + if (row?.type === 'folder' && row.folder === 'inbox') { + target = { folder: 'inbox', subpath: row.subpath ?? '' } + } else if (row?.type === 'note' && row.path) { + const note = state.notes.find((n) => n.path === row.path) + if (note) target = galleryFolderOfNote(note, state.vaultSettings) + } + return target && canHoldKanban(target) ? target : null +} + +/** New Kanban from the palette or `Space k`. */ +export async function newContextKanban(): Promise { + const target = contextKanbanFolder() + const state = useStore.getState() + if (target) await state.createKanban(target.folder, target.subpath) + else await state.newKanban() +} + +/** `:kanban `: a board in the folder the argument names. A bare + * `:kanban` follows the context. */ +export async function newKanbanFromArgument(arg: string): Promise { + if (!arg.trim()) { + await newContextKanban() + return + } + const state = useStore.getState() + const target = resolveGalleryFolderArgument( + arg, + state.folders, + state.vaultSettings, + resolveSystemFolderLabels(state.systemFolderLabels) + ) + if (!target) { + useToastStore.getState().addToast(`No folder called "${arg.trim()}" in this vault`, 'info') + return + } + if (!canHoldKanban(target)) { + useToastStore.getState().addToast(`A Kanban can't go in "${arg.trim()}"`, 'info') + return + } + await state.createKanban(target.folder, target.subpath) +} diff --git a/packages/app-core/src/lib/keyboard-cursor.test.ts b/packages/app-core/src/lib/keyboard-cursor.test.ts new file mode 100644 index 000000000..e5ee84d5c --- /dev/null +++ b/packages/app-core/src/lib/keyboard-cursor.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { useKeyboardCursor } from './keyboard-cursor' + +const original = Object.getOwnPropertyDescriptor(window, 'matchMedia') + +function pointer(coarse: boolean): void { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string) => ({ matches: coarse && query === '(pointer: coarse)' }) + }) +} + +async function read(): Promise { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + let seen: boolean | undefined + function Probe(): null { + seen = useKeyboardCursor() + return null + } + const host = document.createElement('div') + const root = createRoot(host) + await act(async () => root.render(createElement(Probe))) + act(() => root.unmount()) + return seen! +} + +describe('useKeyboardCursor', () => { + afterEach(() => { + if (original) Object.defineProperty(window, 'matchMedia', original) + else delete (window as { matchMedia?: unknown }).matchMedia + }) + + it('draws the cursor where there is a keyboard', async () => { + pointer(false) + expect(await read()).toBe(true) + }) + + it('draws none on a touch-first device, a phone', async () => { + pointer(true) + expect(await read()).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/keyboard-cursor.ts b/packages/app-core/src/lib/keyboard-cursor.ts new file mode 100644 index 000000000..6b954b43e --- /dev/null +++ b/packages/app-core/src/lib/keyboard-cursor.ts @@ -0,0 +1,13 @@ +import { useMemo } from 'react' +import { isTouchPrimaryDevice } from './cm-vim-ime-guard' + +/** + * Whether to draw the keyboard's cursor: the tint, ring or key hints on the + * row, card or cell the keys act on. A touch-first device (a phone) has no + * keyboard to move it, so lists, boards and grids there draw none. Only the + * drawing depends on this; the cursor itself, its refs and the scroll that + * follows it stay as they are. Read once per mount. + */ +export function useKeyboardCursor(): boolean { + return useMemo(() => !isTouchPrimaryDevice(), []) +} diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index db02280cc..47dd794ad 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -84,6 +84,7 @@ export type KeymapId = | "vim.leaderTimeline" | "vim.leaderUnresolvedLinks" | "vim.leaderFolderGallery" + | "vim.leaderNewKanban" | "vim.panePrefix" | "vim.paneFocusLeft" | "vim.paneFocusDown" @@ -923,6 +924,18 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderNewKanban", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader: new Kanban", + description: + "Create a Kanban board, a database with To do / Doing / Done columns, and open it. In the folder under the sidebar cursor while the sidebar has focus, otherwise where new databases go.", + defaultBinding: "k", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.panePrefix", kind: "sequence", diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index fa6bbe1f0..ac90cca5c 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -21,7 +21,7 @@ import { wikilinkDisplayLabel } from './wikilinks' import { classifyLocalAssetHref } from './local-assets' import { parseEmbedSizeHint, splitEmbedLabel } from './excalidraw-preview' import { parseColWidthsComment } from './markdown-table' -import { scanTaskMetadata, type TaskMetaToken } from './task-metadata-tokens' +import { fieldChip, scanTaskMetadata, type TaskMetaToken } from './task-metadata-tokens' import { rollupChildDone, rollupCountsChild, rollupLabel, type ChildTaskState } from './task-rollup' import { customCodeLanguageRegistry, @@ -458,13 +458,17 @@ function remarkTaskMetadata() { const SKIP_TYPES = new Set(['list', 'link', 'linkReference', 'inlineCode', 'code', 'html']) const chipFor = (token: TaskMetaToken): AnyNode => { + const chip = fieldChip(token) const className = token.kind === 'priority' ? ['zen-task-prio', `zen-task-prio-${token.level}`] : token.kind === 'due' ? ['zen-task-meta', 'zen-task-due'] - : ['zen-task-meta', 'zen-task-field'] + : chip.variant + ? ['zen-task-meta', `zen-task-${chip.variant}`, ...(chip.invalid ? ['zen-task-meta-invalid'] : [])] + : ['zen-task-meta', 'zen-task-field'] const hProperties: Record = { className } + if (chip.title) hProperties.title = chip.title if (token.kind === 'due' && token.date) hProperties['data-due'] = token.date return { type: 'emphasis', diff --git a/packages/app-core/src/lib/search-create.ts b/packages/app-core/src/lib/search-create.ts index 3ca62e327..149c8bd0a 100644 --- a/packages/app-core/src/lib/search-create.ts +++ b/packages/app-core/src/lib/search-create.ts @@ -3,6 +3,7 @@ import { formDirContaining } from '@shared/databases' import { parseCreateNotePath } from './wikilinks' import { noteFolderSubpath } from './vault-layout' import { rankTagCompletions, type RankedTag } from './tags' +import { PICKER_AREAS, areaSubfolders, filterFolderChoices } from './folder-picker' /** * Creating a note from the search palette (#826). The pure half of the flow: @@ -160,9 +161,6 @@ export interface DestinationChoice { destination: NoteDestination } -/** Areas the picker offers, in the order they appear. The Trash never does. */ -const PICKER_AREAS: readonly Exclude[] = ['inbox', 'quick', 'archive'] - /** What each top folder is called on screen. The caller resolves renamed * system folders, and a vault that keeps its notes at the root passes the * vault's name for `inbox`, the way the note list heading does. */ @@ -194,21 +192,11 @@ export function buildDestinationChoices( } for (const area of PICKER_AREAS) { push({ folder: area, subpath: '' }) - const seen = new Set() - const subs = folders - .filter((entry) => entry.folder === area && entry.subpath && !formDirContaining(entry.subpath)) - .filter((entry) => !seen.has(entry.subpath) && seen.add(entry.subpath)) - .sort((a, b) => { - const depth = a.subpath.split('/').length - b.subpath.split('/').length - return depth || a.subpath.localeCompare(b.subpath) - }) - for (const entry of subs) push({ folder: area, subpath: entry.subpath }) + for (const subpath of areaSubfolders(folders, area)) push({ folder: area, subpath }) } return choices } -const MAX_DESTINATION_ROWS = 12 - /** * Narrow the picker to what the Folder field says, ranked the way the other * folder prompts rank: an exact value, then values and labels that start @@ -218,28 +206,7 @@ export function filterDestinationChoices( choices: readonly DestinationChoice[], text: string ): DestinationChoice[] { - const query = text.trim().toLowerCase().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') - if (!query) return choices.slice(0, MAX_DESTINATION_ROWS) - return choices - .map((choice, index) => { - const value = choice.value.toLowerCase() - const label = choice.label.toLowerCase() - const rank = - value === query - ? 0 - : value.startsWith(query) - ? 1 - : label.startsWith(query) - ? 2 - : value.includes(query) || label.includes(query) - ? 3 - : null - return rank === null ? null : { choice, rank, index } - }) - .filter((entry): entry is { choice: DestinationChoice; rank: number; index: number } => !!entry) - .sort((a, b) => a.rank - b.rank || a.index - b.index) - .slice(0, MAX_DESTINATION_ROWS) - .map((entry) => entry.choice) + return filterFolderChoices(choices, text) } /** The same shape `extractTags` accepts: a letter, then letters, digits, `_`, `-`, `/`. */ diff --git a/packages/app-core/src/lib/self-keyed-surfaces.ts b/packages/app-core/src/lib/self-keyed-surfaces.ts index c3264c46b..bfbee3393 100644 --- a/packages/app-core/src/lib/self-keyed-surfaces.ts +++ b/packages/app-core/src/lib/self-keyed-surfaces.ts @@ -26,6 +26,8 @@ export const SELF_KEYED_SURFACES = [ // Runs its own vim-style motion grid. '[data-zen-db-grid]', + // The database Board: h / j / k / l, H / L and the arrows move its cursor. + '[data-zen-db-board]', // The grid's Columns popover: j / k and x with Vim on, the arrows and // Space with it off, and its Escape closes it rather than leaving the grid. '[data-db-columns-menu]', diff --git a/packages/app-core/src/lib/task-check-off.ts b/packages/app-core/src/lib/task-check-off.ts new file mode 100644 index 000000000..da878e65c --- /dev/null +++ b/packages/app-core/src/lib/task-check-off.ts @@ -0,0 +1,30 @@ +import { completeRecurringTaskAtIndex } from '@shared/task-recurrence' +import { toggleTaskAtIndex } from '@shared/tasklists' +import { toIsoDateLocal } from '@shared/tasks' +import { useStore } from '../store' +import { dailyNoteDueForPath } from './vault-layout' + +/** The date a daily note gives its tasks, when `path` is one and its tasks + * take it. A recurring task without `due:` counts from it (#1036). */ +export function dailyDueForNote(path: string | null | undefined): string | undefined { + if (!path) return undefined + const state = useStore.getState() + return dailyNoteDueForPath(state.notes, state.vaultSettings, path) +} + +/** + * `markdown` with the task at `taskIndex` checked off. A recurring task writes + * its next occurrence above itself (#1036); any other task just gets its `x`. + * For surfaces that edit a note's body directly, like Preview's checkboxes. + */ +export function checkOffTaskInMarkdown( + markdown: string, + taskIndex: number, + notePath: string | null | undefined +): string { + const recurring = completeRecurringTaskAtIndex(markdown, taskIndex, { + today: toIsoDateLocal(new Date()), + fallbackDue: dailyDueForNote(notePath) + }) + return recurring?.body ?? toggleTaskAtIndex(markdown, taskIndex, true) +} diff --git a/packages/app-core/src/lib/task-context-menu.ts b/packages/app-core/src/lib/task-context-menu.ts index 9e23136bb..2a3fa5b22 100644 --- a/packages/app-core/src/lib/task-context-menu.ts +++ b/packages/app-core/src/lib/task-context-menu.ts @@ -4,6 +4,8 @@ import type { ContextMenuItem } from '../components/ContextMenu' import { useStore } from '../store' import { promptDate } from './date-prompt-requests' import { forwardTaskWithPicker } from './forward-task' +import { parseRepeatRule } from '@shared/task-recurrence' +import { chooseTaskRemind, chooseTaskRepeat } from './task-recurrence-pickers' /** * The right-click menu for a task, shared by the Tasks list, the Kanban board @@ -105,15 +107,15 @@ export function buildTaskMenuItems( onSelect: () => void forwardTaskWithPicker(task) }, { kind: 'separator' }, - { label: 'Due today', hint: dueIn(0), onSelect: () => mutate({ kind: 'set-due', due: dueIn(0) }) }, + { label: 'Due today', mark: dueIn(0), onSelect: () => mutate({ kind: 'set-due', due: dueIn(0) }) }, { label: 'Due tomorrow', - hint: dueIn(1), + mark: dueIn(1), onSelect: () => mutate({ kind: 'set-due', due: dueIn(1) }) }, { label: 'Due next week', - hint: dueIn(7), + mark: dueIn(7), onSelect: () => mutate({ kind: 'set-due', due: dueIn(7) }) }, { label: 'Choose a date…', hint: key('@'), onSelect: () => void chooseTaskDueDate(task, today) } @@ -124,6 +126,28 @@ export function buildTaskMenuItems( items.push({ label: 'Clear due date', onSelect: () => mutate({ kind: 'set-due', due: null }) }) } + // Recurrence (#1036). Whole-note tasks keep their state in frontmatter, which + // repeats on TaskNotes' terms, not yet built. + const repeats = parseRepeatRule(task.fields?.repeat) != null + items.push( + { kind: 'separator' }, + { + label: 'Repeat…', + hint: key('r'), + disabled: task.kind === 'file', + onSelect: () => void chooseTaskRepeat(task) + }, + { + label: 'Remind…', + hint: key('R'), + disabled: task.kind === 'file', + onSelect: () => void chooseTaskRemind(task) + } + ) + if (repeats && task.kind !== 'file' && !task.checked && !task.cancelled && !task.forwarded) { + items.push({ label: 'Skip this time', onSelect: () => void state.skipTaskOccurrence(task) }) + } + items.push( { kind: 'separator' }, { diff --git a/packages/app-core/src/lib/task-metadata-tokens.ts b/packages/app-core/src/lib/task-metadata-tokens.ts index 42c463999..9ac365f09 100644 --- a/packages/app-core/src/lib/task-metadata-tokens.ts +++ b/packages/app-core/src/lib/task-metadata-tokens.ts @@ -8,6 +8,9 @@ * Token shapes mirror the parser in `@shared/tasks`. */ +import { describeRepeatRule, parseRepeatRule } from '@shared/task-recurrence' +import { describeRemindSpec, parseRemindSpec } from '@shared/task-reminders' + export type TaskMetaKind = 'priority' | 'due' | 'field' export interface TaskMetaToken { @@ -21,6 +24,8 @@ export interface TaskMetaToken { level?: 'high' | 'med' | 'low' /** Only for `due` — the ISO date, without the `due:` prefix. */ date?: string + /** Only for `field`: its key, lower-cased (`repeat` for `@repeat:weekly`). */ + field?: string } // Anchored on `(^|\s)` so `!`, `due:` or `@` glued to a preceding word don't match. @@ -61,7 +66,9 @@ export function scanTaskMetadata(text: string): TaskMetaToken[] { FIELD_RE.lastIndex = 0 while ((m = FIELD_RE.exec(text)) !== null) { const start = m.index + m[1].length - tokens.push({ kind: 'field', start, end: start + m[2].length, text: m[2] }) + const colon = m[2].indexOf(':') + const field = (colon > 0 ? m[2].slice(1, colon) : m[2].slice(1)).toLowerCase() + tokens.push({ kind: 'field', start, end: start + m[2].length, text: m[2], field }) } tokens.sort((a, b) => a.start - b.start) @@ -75,3 +82,32 @@ export function todayIso(): string { const dd = String(d.getDate()).padStart(2, '0') return `${d.getFullYear()}-${mm}-${dd}` } + +export interface FieldChip { + /** The chip's own class suffix: `repeat`, `remind`, or null for a plain field. */ + variant: 'repeat' | 'remind' | null + /** A value the field cannot mean anything with draws dimmed. */ + invalid: boolean + /** What the chip means, for its tooltip. */ + title?: string +} + +/** How a field token draws. The recurring-task fields (#1036) get their own + * colors and say what they mean on hover; every other field is plain. */ +export function fieldChip(token: TaskMetaToken): FieldChip { + if (token.kind !== 'field') return { variant: null, invalid: false } + const value = token.text.slice(token.text.indexOf(':') + 1) + if (token.field === 'repeat') { + const rule = parseRepeatRule(value) + return rule + ? { variant: 'repeat', invalid: false, title: `Repeats: ${describeRepeatRule(rule)}` } + : { variant: 'repeat', invalid: true, title: 'Unknown repeat rule' } + } + if (token.field === 'remind') { + const spec = parseRemindSpec(value) + return spec + ? { variant: 'remind', invalid: false, title: `Reminder: ${describeRemindSpec(spec)}` } + : { variant: 'remind', invalid: true, title: 'Unknown reminder time' } + } + return { variant: null, invalid: false } +} diff --git a/packages/app-core/src/lib/task-recurrence-pickers.ts b/packages/app-core/src/lib/task-recurrence-pickers.ts new file mode 100644 index 000000000..1706d8e26 --- /dev/null +++ b/packages/app-core/src/lib/task-recurrence-pickers.ts @@ -0,0 +1,114 @@ +/** + * The Repeat… and Remind… pickers (#1036): `r` / `R` on every Tasks surface, + * the task menu, and the command palette all open these. Each is the app's + * prompt with a short list of presets, so the common choices are one Enter + * away and anything else (`2w`, `mon/thu`, `2d-6pm`) is typed in place. + * + * Both write a plain `@repeat:` / `@remind:` field through the queued task + * writer, like every other task edit from a list. + */ +import type { VaultTask } from '@shared/tasks' +import { parseRepeatRule } from '@shared/task-recurrence' +import { formatClock, parseHhMm, parseRemindSpec } from '@shared/task-reminders' +import type { PromptSuggestion } from '../components/PromptModal' +import { useStore } from '../store' +import { promptApp } from './prompt-requests' +import { useToastStore } from './toast' + +const REPEAT_SUGGESTIONS: PromptSuggestion[] = [ + { value: 'daily', label: 'Daily' }, + { value: 'weekdays', label: 'Weekdays', detail: 'Mon to Fri' }, + { value: 'weekly', label: 'Weekly' }, + { value: '2w', label: 'Every 2 weeks' }, + { value: 'monthly', label: 'Monthly' }, + { value: 'yearly', label: 'Yearly' } +] + +const OFF = 'off' + +function fileTaskNotice(): void { + useToastStore.getState().addToast('Repeat and reminders work on checkbox tasks.', 'info') +} + +/** Ask how a task repeats. Resolves to a rule, `null` to stop repeating, or + * `undefined` when the prompt was dismissed or nothing changed. */ +export async function promptRepeatRule( + description: string, + current: string | undefined +): Promise { + const value = await promptApp({ + title: 'Repeat', + description, + placeholder: 'weekly, 2w, mon/thu, after-3d', + okLabel: 'Repeat', + plainInput: true, + autoHighlightFirst: true, + suggestions: [ + ...REPEAT_SUGGESTIONS.map((s) => (s.value === current ? { ...s, detail: 'current' } : s)), + ...(current ? [{ value: OFF, label: 'Stop repeating' }] : []) + ], + validate: (raw) => { + const v = raw.trim().toLowerCase() + return !v || v === OFF || parseRepeatRule(v) ? null : 'Try weekly, 2w, mon/thu or after-3d' + } + }) + const rule = value?.trim().toLowerCase() + if (!rule || rule === current) return undefined + return rule === OFF ? null : rule +} + +/** Ask when a task reminds. Resolves like {@link promptRepeatRule}. */ +export async function promptRemindWhen( + description: string, + current: string | undefined +): Promise { + const state = useStore.getState() + const at = formatClock(parseHhMm(state.taskReminderTime), state.timeFormat) + const presets: PromptSuggestion[] = [ + { value: '0d', label: 'Morning of', detail: at }, + { value: '1d', label: 'Day before', detail: at }, + { value: '9am', label: '9:00 AM' }, + { value: '6pm', label: '6:00 PM' } + ] + const value = await promptApp({ + title: 'Remind', + description, + placeholder: '9am, 1730, 1d, 2d-6pm', + okLabel: 'Remind', + plainInput: true, + autoHighlightFirst: true, + suggestions: [ + ...presets.map((s) => (s.value === current ? { ...s, detail: 'current' } : s)), + ...(current ? [{ value: OFF, label: 'No reminder' }] : []) + ], + validate: (raw) => { + const v = raw.trim().toLowerCase() + return !v || v === OFF || parseRemindSpec(v) ? null : 'Try 9am, 1730, 1d or 2d-6pm' + } + }) + const when = value?.trim().toLowerCase() + if (!when || when === current) return undefined + return when === OFF ? null : when +} + +/** Pick how `task` repeats, or stop it repeating. */ +export async function chooseTaskRepeat(task: VaultTask): Promise { + if (task.kind === 'file') return fileTaskNotice() + const rule = await promptRepeatRule(task.content, task.fields?.repeat) + if (rule === undefined) return + await useStore.getState().applyTaskMutation(task, { kind: 'set-field', key: 'repeat', value: rule }) +} + +/** Pick when `task` reminds, or take its reminder off. */ +export async function chooseTaskRemind(task: VaultTask): Promise { + if (task.kind === 'file') return fileTaskNotice() + const when = await promptRemindWhen(task.content, task.fields?.remind) + if (when === undefined) return + await useStore.getState().applyTaskMutation(task, { kind: 'set-field', key: 'remind', value: when }) + if (when && !task.due) noDueNotice() +} + +/** Said when a reminder lands on a task with no date to be reminded of. */ +export function noDueNotice(): void { + useToastStore.getState().addToast('A reminder needs a due date.', 'info') +} diff --git a/packages/app-core/src/lib/task-reminders-runtime.ts b/packages/app-core/src/lib/task-reminders-runtime.ts new file mode 100644 index 000000000..6e182633a --- /dev/null +++ b/packages/app-core/src/lib/task-reminders-runtime.ts @@ -0,0 +1,109 @@ +/** + * Task reminders in the running app (#1036): keep the host's schedule in step + * with the tasks, and open the task a clicked reminder names. + * + * The schedule is computed here, from the task index the app already keeps, + * so a local vault and a self-hosted one remind the same way. The host (the + * desktop main process) only times and shows it. + */ +import { filterTasksForDisplay, inferDailyTaskDueDates } from '@shared/tasks' +import { + computeTaskReminders, + plainTaskTitle, + type TaskReminder, + type TaskReminderTarget +} from '@shared/task-reminders' +import { useStore } from '../store' +import { buildDailyNoteDateByPath } from './vault-layout' + +/** The reminders the open tasks ask for, as the Tasks views see the tasks: + * trash and (unless shown) archived notes out, daily-note dates in. */ +export function currentTaskReminders(now = new Date()): TaskReminder[] { + const s = useStore.getState() + if (!s.vault || !s.taskReminders) return [] + const live = new Set(s.notes.filter((n) => n.folder !== 'trash').map((n) => n.path)) + const shown = filterTasksForDisplay(s.vaultTasks, s.showArchivedTasks).filter((t) => + live.has(t.sourcePath) + ) + const dated = inferDailyTaskDueDates(shown, buildDailyNoteDateByPath(s.notes, s.vaultSettings)) + return computeTaskReminders(dated, { now, defaultTime: s.taskReminderTime }) +} + +/** + * Send the schedule whenever it changes, debounced, and hourly so the + * week-ahead window moves with the clock. Nothing is sent until the vault's + * tasks are indexed: an empty schedule at startup would read to the host as + * "nothing to catch up on". + */ +export function startTaskReminderReporter(): () => void { + if (!window.zen.setTaskReminders) return () => undefined + let indexed = useStore.getState().vaultTasks.length > 0 + let last: string | null = null + let timer: ReturnType | null = null + + const send = (): void => { + timer = null + if (!indexed) return + const list = currentTaskReminders() + const key = JSON.stringify(list) + if (key === last) return + last = key + window.zen.setTaskReminders?.(list) + } + const schedule = (): void => { + if (timer == null) timer = setTimeout(send, 1000) + } + + const unsubscribe = useStore.subscribe((s, prev) => { + if (s.vault !== prev.vault) { + indexed = false + last = null + } + if ((prev.tasksLoading && !s.tasksLoading) || s.vaultTasks.length > 0) indexed = true + if ( + s.vaultTasks !== prev.vaultTasks || + s.notes !== prev.notes || + s.vaultSettings !== prev.vaultSettings || + s.showArchivedTasks !== prev.showArchivedTasks || + s.taskReminders !== prev.taskReminders || + s.taskReminderTime !== prev.taskReminderTime || + s.tasksLoading !== prev.tasksLoading + ) { + schedule() + } + }) + const hourly = setInterval(schedule, 3_600_000) + schedule() + return () => { + unsubscribe() + clearInterval(hourly) + if (timer != null) clearTimeout(timer) + } +} + +/** Open what a clicked reminder names: its task (found again by note and + * text, since lines may have moved), the note when the task is gone, or the + * Tasks view for a folded notification. */ +export async function openTaskReminderTarget(target: TaskReminderTarget): Promise { + const state = useStore.getState() + if (!target) { + await state.openTasksView() + return + } + const same = state.vaultTasks.filter( + (t) => t.sourcePath === target.path && plainTaskTitle(t.content) === target.title && !t.checked + ) + const task = same.find((t) => t.taskIndex === target.taskIndex) ?? same[0] + if (task) { + await state.openTaskAt(task) + return + } + if (state.notes.some((n) => n.path === target.path)) { + await state.openNoteInPane(state.activePaneId, target.path) + } +} + +/** Listen for clicked reminders. */ +export function listenForTaskReminderOpen(): () => void { + return window.zen.onTaskReminderOpen?.((target) => void openTaskReminderTarget(target)) ?? (() => undefined) +} diff --git a/packages/app-core/src/lib/tasks-keys.test.ts b/packages/app-core/src/lib/tasks-keys.test.ts index 8990cc82b..736ff9c57 100644 --- a/packages/app-core/src/lib/tasks-keys.test.ts +++ b/packages/app-core/src/lib/tasks-keys.test.ts @@ -35,9 +35,9 @@ describe('the Tasks keys cheat sheet', () => { expect(calendar(false)).not.toContain('a') }) - it('lists the board keys with Vim mode on, the WIP limit among them', () => { + it('lists the board keys with Vim mode on, the column colour and WIP limit among them', () => { const keys = keysOf(tasksKeySheet('kanban', { vimMode: true, dndEnabled: true, listOrder: 'note' })) - for (const key of ['h / l', 'j / k', 'x', 'Enter', 'Shift+H / L', 'm', '< / >', 'W', 'g', 's', 'v', 'E', '?']) { + for (const key of ['h / l', 'j / k', 'x', 'Enter', 'Shift+H / L', 'm', '< / >', 'C', 'W', 'g', 's', 'v', 'E', '?']) { expect(keys).toContain(key) } }) diff --git a/packages/app-core/src/lib/tasks-keys.ts b/packages/app-core/src/lib/tasks-keys.ts index 256299683..c8fd5858f 100644 --- a/packages/app-core/src/lib/tasks-keys.ts +++ b/packages/app-core/src/lib/tasks-keys.ts @@ -153,6 +153,7 @@ function kanbanGroups({ vimMode, dndEnabled, lanes, laneMoves }: TasksKeySheetOp { keys: 'x', label: 'toggle' }, { keys: 'Enter', label: 'open' }, ...(dndEnabled ? [{ keys: 'Shift+H / L', label: 'move' }] : []), + { keys: 'r / R', label: 'repeat, remind' }, { keys: 'm', label: 'menu' } ] }, @@ -160,6 +161,7 @@ function kanbanGroups({ vimMode, dndEnabled, lanes, laneMoves }: TasksKeySheetOp title: 'Columns', rows: [ { keys: '< / >', label: 'reorder' }, + { keys: 'C', label: 'color' }, { keys: 'W', label: 'WIP limit' }, { keys: 'Enter', label: 'rename (header)' } ] @@ -209,6 +211,7 @@ function listSheet({ vimMode, listOrder }: TasksKeySheetOptions): TasksKeySheet { keys: 'i', label: 'start' }, { keys: 'c', label: 'cancel' }, { keys: '@', label: 'due date' }, + { keys: 'r / R', label: 'repeat, remind' }, { keys: '>', label: 'forward' } ] }, @@ -272,6 +275,7 @@ function calendarSheet({ vimMode, dayAdd }: TasksKeySheetOptions): TasksKeySheet { keys: '< / >', label: 'due ±1 day' }, { keys: 'T', label: 'due today' }, { keys: '@', label: 'due date' }, + { keys: 'r / R', label: 'repeat, remind' }, ...(dayAdd ? [{ keys: 'a', label: 'add to day' }] : []) ] }, diff --git a/packages/app-core/src/lib/use-folder-picker.ts b/packages/app-core/src/lib/use-folder-picker.ts new file mode 100644 index 000000000..5b9d4a76d --- /dev/null +++ b/packages/app-core/src/lib/use-folder-picker.ts @@ -0,0 +1,66 @@ +import { useMemo, useState } from 'react' +import type { KeyboardEvent } from 'react' +import { isPaletteNextKey, isPalettePreviousKey } from './palette-nav' +import { + DEFAULT_FOLDER_PICKER_ROWS, + filterFolderChoices, + stepPickerIndex, + type FolderChoice +} from './folder-picker' + +export interface FolderPicker { + /** The rows for the field's text. Every folder until the field is typed + * in, even when it arrived filled: landing in it means the user came to + * look, or to change it. */ + rows: T[] + /** The highlighted row, or -1 for the typed value. */ + active: number + /** The row Enter would pick. A highlight can outlive its row when typing + * empties the list, so this reads the row, not just the index. */ + highlighted: T | undefined + setActive: (index: number) => void + /** The field took focus: list every folder, highlight none. */ + reset: () => void + /** The field's text changed: narrow the rows and highlight the first, so + * Enter picks it without an arrow key first. The typed value stays one + * ArrowUp away, for a folder that does not exist yet. (#467) */ + typed: (text: string) => void + /** Move the highlight on the palette keys (the arrows, Ctrl+N / Ctrl+P, + * Ctrl+J / Ctrl+K). True when the key was one of them. */ + step: (event: KeyboardEvent) => boolean +} + +/** The keyboard half of a folder picker, shared by every field that has one. */ +export function useFolderPicker( + choices: readonly T[], + text: string, + limit: number = DEFAULT_FOLDER_PICKER_ROWS +): FolderPicker { + const [active, setActive] = useState(-1) + const [narrowed, setNarrowed] = useState(false) + const rows = useMemo( + () => filterFolderChoices(choices, narrowed ? text : '', limit), + [choices, narrowed, text, limit] + ) + return { + rows, + active, + highlighted: active >= 0 ? rows[active] : undefined, + setActive, + reset: () => { + setActive(-1) + setNarrowed(false) + }, + typed: (next) => { + setNarrowed(true) + setActive(next.trim() ? 0 : -1) + }, + step: (event) => { + const delta = isPaletteNextKey(event) ? 1 : isPalettePreviousKey(event) ? -1 : 0 + if (!delta) return false + event.preventDefault() + setActive((current) => stepPickerIndex(current, delta, rows.length)) + return true + } + } +} diff --git a/packages/app-core/src/lib/vault-layout.test.ts b/packages/app-core/src/lib/vault-layout.test.ts index 7023fd84b..e94c41568 100644 --- a/packages/app-core/src/lib/vault-layout.test.ts +++ b/packages/app-core/src/lib/vault-layout.test.ts @@ -15,6 +15,7 @@ import { noteFolderLabel, noteFolderSubpath, normalizeVaultSettings, + reservedCreateFolderTop, resolveCreateLocation, specificFolderDestination, parseFavoriteFolderKey, @@ -834,6 +835,37 @@ describe('resolveCreateLocation (#362)', () => { }) }) +describe('a specific folder in a reserved root folder', () => { + const inboxVault = normalizeVaultSettings(null) + const rootVault = normalizeVaultSettings({ primaryNotesLocation: 'root' } as VaultSettings) + + it('creates in the primary location of a root vault instead of in assets/', () => { + expect(reservedCreateFolderTop('assets/drawings', rootVault)).toBe('assets') + expect( + resolveCreateLocation({ mode: 'folder', folder: 'assets/drawings' }, null, rootVault) + ).toEqual({ folder: 'inbox', subpath: '' }) + expect(specificFolderDestination('assets/drawings', rootVault)).toBe('') + }) + + it('treats every attachments folder and a hidden folder the same, in any case', () => { + expect(reservedCreateFolderTop('Assets/Drawings', rootVault)).toBe('Assets') + expect(reservedCreateFolderTop('attachements/x', rootVault)).toBe('attachements') + expect(reservedCreateFolderTop('_assets', rootVault)).toBe('_assets') + expect(reservedCreateFolderTop('.drawings', rootVault)).toBe('.drawings') + expect(reservedCreateFolderTop('/.zennotes/x/', rootVault)).toBe('.zennotes') + }) + + it('leaves ordinary folders, and every folder of an Inbox vault, alone', () => { + expect(reservedCreateFolderTop('Drawings', rootVault)).toBeNull() + expect(reservedCreateFolderTop('Projects/assets', rootVault)).toBeNull() + expect(reservedCreateFolderTop('', rootVault)).toBeNull() + expect(reservedCreateFolderTop('assets/drawings', inboxVault)).toBeNull() + expect( + resolveCreateLocation({ mode: 'folder', folder: 'assets/drawings' }, null, inboxVault) + ).toEqual({ folder: 'inbox', subpath: 'assets/drawings' }) + }) +}) + describe('specificFolderDestination', () => { const inboxVault = normalizeVaultSettings(null) const rootVault = normalizeVaultSettings({ primaryNotesLocation: 'root' } as VaultSettings) diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 287c1c4be..5dbd12aaa 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -540,7 +540,8 @@ function normalizeFileLocation( * - `primary` → the primary notes location root (default; the pre-#362 behavior) * - `active-note` → the folder of the active note (falls back to primary when * nothing is open or the note is in Trash) - * - `folder` → the given subfolder of the primary location + * - `folder` → the given subfolder of the primary location, unless it + * lands in a reserved folder (see `reservedCreateFolderTop`) */ export function resolveCreateLocation( setting: FileLocationSetting | null | undefined, @@ -551,12 +552,35 @@ export function resolveCreateLocation( if (normalized.mode === 'active-note' && activeNote && activeNote.folder !== 'trash') { return { folder: activeNote.folder, subpath: noteFolderSubpath(activeNote, settings) } } - if (normalized.mode === 'folder' && normalized.folder) { + if ( + normalized.mode === 'folder' && + normalized.folder && + !reservedCreateFolderTop(normalized.folder, settings) + ) { return { folder: 'inbox', subpath: normalized.folder } } return { folder: 'inbox', subpath: '' } } +/** + * The top-level folder that keeps a `Specific folder` location from holding + * notes, or null when it can. Only a root vault has one: there the location's + * first segment is a top-level directory, and `assets/` (with the other + * attachment folders) and dot folders hold files the vault never lists or + * opens as notes. A drawing created in `assets/drawings` was written, then + * could not be opened, so New Drawing seemed to do nothing (charles, Discord, + * 2026-11-01); such a location now creates in the primary location instead. + */ +export function reservedCreateFolderTop( + folder: string | null | undefined, + settings: VaultSettings | null | undefined +): string | null { + const clean = (folder ?? '').trim().replace(/^\/+|\/+$/g, '') + if (!clean) return null + const top = vaultRelativeFolderPath('inbox', clean, settings).split('/')[0] ?? '' + return top.startsWith('.') || RESERVED_NON_SYSTEM_ROOT_NAMES.has(top.toLowerCase()) ? top : null +} + /** * The vault-relative directory a `Specific folder` location really creates * files in ('' is the vault root), so Settings can spell it out. The folder is @@ -1495,6 +1519,21 @@ export function buildDailyNoteDateByPath( return out } +/** The date one note's tasks take from it, the single-note form of + * {@link buildDailyNoteDateByPath}: a recurring task without a `due:` counts + * its next occurrence from it (#1036). */ +export function dailyNoteDueForPath( + notes: readonly NoteMeta[], + settings: VaultSettings | null | undefined, + path: string +): string | undefined { + const normalized = normalizeVaultSettings(settings) + if (!normalized.dailyNotes.enabled || !normalized.dailyNotes.tasksDueOnNoteDate) return undefined + const note = notes.find((n) => n.path === path) + const info = note ? classifyDateNote(note, normalized) : null + return info?.kind === 'daily' ? noteTitleForDate(info.date) : undefined +} + export function dateNoteFolderMayBelongToDatePattern( subpath: string, settings: VaultSettings | null | undefined diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index bb55d22e0..00225e104 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,35 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.81.0', + highlights: [ + { + title: 'Recurring tasks and reminders', + body: 'Add @repeat:weekly to a task and checking it off brings the next one back, subtasks and all. @remind:1d sends a desktop reminder. r and R in Tasks.' + }, + { + title: 'A Kanban board for any folder', + body: 'Right-click a folder and choose New Kanban: a board of its own with To do, Doing and Done. Space k in Vim mode.' + }, + { + title: 'Colors for Kanban columns', + body: 'Give a column a color from its menu, or C in Vim mode, drawn in your theme’s own shade.' + }, + { + title: 'Pick folders, don’t type them', + body: 'Every Settings field that names a folder lists your vault’s folders as you type.' + }, + { + title: 'Lists keep their bullets together', + body: 'Enter opens the next bullet right below, even after a blank line between the first two.' + }, + { + title: 'zn’s settings stay put', + body: 'Saving settings no longer drops what only zn uses from config.toml. The bundled zn is 0.14.0.' + } + ] + }, { version: '2.80.1', highlights: [ diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index e40cec292..4a2f5f859 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -1596,6 +1596,29 @@ describe('note jump history with the Tasks and Tags views (#633)', () => { expect(useStore.getState().selectedPath).toBe('inbox/Other.md') }) + it('a task opened from the Tasks view leaves Tasks behind it, so Ctrl+O and Back return there', async () => { + // openTaskAt opened the note through the raw tab primitive and recorded + // no jump, so Ctrl+O (and the phones' Back) skipped Tasks and landed on + // the note before it (2.81.0 iPhone walk). + installZen({ + readNote: vi + .fn() + .mockImplementation(async (path: string) => makeNote('- [ ] Pay rent', path)) + }) + const { useStore } = await loadStore() + + await useStore.getState().selectNote('inbox/From.md') + await useStore.getState().openTasksView() + await useStore.getState().openTaskAt(makeTask('Pay rent')) + expect(useStore.getState().selectedPath).toBe('inbox/Note.md') + expect(useStore.getState().noteBackstack.map((l) => l.path).at(-1)).toBe(TASKS_TAB_PATH) + + await useStore.getState().jumpToPreviousNote() + expect(useStore.getState().selectedPath).toBe(TASKS_TAB_PATH) + await useStore.getState().jumpToPreviousNote() + expect(useStore.getState().selectedPath).toBe('inbox/From.md') + }) + it('the Tags view round-trips the same way', async () => { installZen({ readNote: vi @@ -2218,6 +2241,62 @@ describe('kanbanWipLimits (#1025)', () => { }) }) +describe('kanbanColumnColors (#1037)', () => { + it('keeps a colour per board and column, through a restart', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().kanbanColumnColors).toEqual({}) + + useStore.getState().setKanbanColumnColor('status', 'in-progress', 'blue') + useStore.getState().setKanbanColumnColor('field:status', 'done', 'green') + expect(useStore.getState().kanbanColumnColors).toEqual({ + 'status:in-progress': 'blue', + 'field:status:done': 'green' + }) + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.kanbanColumnColors).toEqual({ 'status:in-progress': 'blue', 'field:status:done': 'green' }) + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().kanbanColumnColors['status:in-progress']).toBe('blue') + }) + + it('clears a colour with null and leaves the other columns alone', async () => { + installZen() + const { useStore } = await loadStore() + useStore.getState().setKanbanColumnColor('status', 'in-progress', 'blue') + useStore.getState().setKanbanColumnColor('status', 'waiting', 'yellow') + useStore.getState().setKanbanColumnColor('status', 'in-progress', null) + expect(useStore.getState().kanbanColumnColors).toEqual({ 'status:waiting': 'yellow' }) + }) + + it('reads the names a hand-edited config.toml can mean, and drops the rest', async () => { + installZen() + vi.resetModules() + localStorage.clear() + localStorage.setItem( + 'zen:prefs:v2', + JSON.stringify({ + kanbanColumnColors: { + 'status:in-progress': 'Blue', + 'status:waiting': 'grey', + 'priority:high': '#ff0000', + 'priority:med': 'orange', + 'priority:low': 3, + 'folder:Projects/alpha': 'aqua', + bogus: 'red' + } + }) + ) + const { useStore } = await import('./store') + expect(useStore.getState().kanbanColumnColors).toEqual({ + 'status:in-progress': 'blue', + 'status:waiting': 'gray', + 'folder:Projects/alpha': 'teal' + }) + }) +}) + describe('kanbanCardSort (#889)', () => { it('defaults to the manual order and round-trips due-date ordering', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index fed13ca3e..2c5e432bd 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -5,6 +5,7 @@ import { isNoteEditingLocked, lockNoteEditing } from './lib/note-lifecycle-lock' import { create } from 'zustand' import { rewriteWikilinksForRename } from '@shared/wikilink-rename' import { useToastStore } from './lib/toast' +import { parseKanbanColumnColor, type KanbanColumnColor } from './lib/kanban-column-colors' import type { EditorView } from '@codemirror/view' import { editorCursorPosition, @@ -65,6 +66,7 @@ import { isObsidianExcalidrawPath } from '@shared/excalidraw' import { TASKS_TAB_PATH, isTasksTabPath, parseTasksFromBody, toIsoDateLocal } from '@shared/tasks' +import { completeRecurringTaskAtIndex, type RecurringCompletion } from '@shared/task-recurrence' import { TYPST_PREAMBLE_FOLDER, isTypstPreamblePath, @@ -92,6 +94,7 @@ import { isDatabaseTabPath, isDatabaseCsvPath } from '@shared/databases' +import { KANBAN_DATABASE_TITLE, kanbanDatabaseSeed } from '@shared/database-csv' import { parseFrontmatter } from '@shared/template-files' import { recordTitle, composePageBody, planRecordPages, recordPageToAdopt } from './lib/database-cells' import { applyManualMove, manualOrderCompare, parentDirOf } from './lib/manual-order' @@ -184,6 +187,7 @@ import { findWeeklyNoteForDate, findMonthlyNoteForDate, noteTitleForDate, + dailyNoteDueForPath, isPrimaryNotesAtRoot, removeFavoritesForFolder, removeFolderColors, @@ -865,10 +869,20 @@ interface Prefs { * list, calendar and Kanban board, the Calendar and Timeline panels and * Home. On by default; portable as `hide_empty_tasks` under [view]. (#1026) */ hideEmptyTasks: boolean + /** Notify for tasks with `@remind:` (#1036). On by default: only a task + * someone gave a reminder ever notifies. Portable as `task_reminders`. */ + taskReminders: boolean + /** `HH:MM`: when a reminder that names a day but no time fires + * (`@remind:1d`). Portable as `task_reminder_time`. */ + taskReminderTime: string /** Kanban WIP limits: the most cards a column should hold, keyed by * `${groupBy}:${columnId}` like the column titles. Portable as the * `[kanban_wip_limits]` table. (#1025) */ kanbanWipLimits: Record + /** Kanban column colours, palette names keyed by `${groupBy}:${columnId}` + * like the column titles. Portable as the `[kanban_column_colors]` table. + * (#1037) */ + kanbanColumnColors: Record /** A second field that splits the Kanban board into horizontal lanes, or * 'none'. Portable as `kanban_lanes` under [view], like the card order: a * global display choice, not a per-vault override, which the Go server's @@ -1069,7 +1083,8 @@ function normalizeKanbanColumnTitles(raw: unknown): Record { /** The largest WIP limit a column takes; TasksKanban's parseWipLimitInput * holds typed limits to the same bound. */ export const MAX_KANBAN_WIP_LIMIT = 999 -const MAX_KANBAN_WIP_LIMIT_COLUMNS = 256 +// The most columns that keep a WIP limit, or a colour, before the rest drop. +const MAX_KANBAN_SETTING_COLUMNS = 256 function normalizeKanbanWipLimit(raw: unknown): number | null { // A hand-edited config.toml can quote the number; anything that is not a @@ -1079,6 +1094,14 @@ function normalizeKanbanWipLimit(raw: unknown): number | null { return Number.isInteger(value) && value >= 1 && value <= MAX_KANBAN_WIP_LIMIT ? value : null } +/** A reminder time as Settings and config.toml keep it: `HH:MM`, 24-hour, + * zero-padded. Anything else falls back to 09:00. */ +export function normalizeReminderTime(raw: unknown): string { + const m = typeof raw === 'string' ? /^(\d{1,2}):(\d{2})$/.exec(raw.trim()) : null + if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) return '09:00' + return `${m[1].padStart(2, '0')}:${m[2]}` +} + // WIP limits per column (#1025): `{ ":": }`, the // column-title key grammar with a whole-number value. export function normalizeKanbanWipLimits(raw: unknown): Record { @@ -1091,7 +1114,24 @@ export function normalizeKanbanWipLimits(raw: unknown): Record { if (limit === null) continue out[key] = limit columns += 1 - if (columns >= MAX_KANBAN_WIP_LIMIT_COLUMNS) break + if (columns >= MAX_KANBAN_SETTING_COLUMNS) break + } + return out +} + +// Column colours (#1037): `{ ":": "" }`. A +// hand-edited name the palette does not have is dropped, never guessed at. +export function normalizeKanbanColumnColors(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + const out: Record = {} + let columns = 0 + for (const [key, value] of Object.entries(raw as Record)) { + if (!isKanbanColumnKey(key)) continue + const color = parseKanbanColumnColor(value) + if (color === null) continue + out[key] = color + columns += 1 + if (columns >= MAX_KANBAN_SETTING_COLUMNS) break } return out } @@ -1419,7 +1459,10 @@ export const DEFAULT_PREFS: Prefs = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', hideEmptyTasks: true, + taskReminders: true, + taskReminderTime: '09:00', kanbanWipLimits: {}, + kanbanColumnColors: {}, kanbanLanes: 'none', kanbanCollapsedLanes: {}, tasksListOrder: 'note', @@ -1809,7 +1852,11 @@ function normalizePrefs(p: Partial): Prefs { kanbanCardSort: p.kanbanCardSort === 'due' ? 'due' : 'manual', hideEmptyTasks: typeof p.hideEmptyTasks === 'boolean' ? p.hideEmptyTasks : DEFAULT_PREFS.hideEmptyTasks, + taskReminders: + typeof p.taskReminders === 'boolean' ? p.taskReminders : DEFAULT_PREFS.taskReminders, + taskReminderTime: normalizeReminderTime(p.taskReminderTime), kanbanWipLimits: normalizeKanbanWipLimits(p.kanbanWipLimits), + kanbanColumnColors: normalizeKanbanColumnColors(p.kanbanColumnColors), kanbanLanes: normalizeKanbanLanes(p.kanbanLanes), kanbanCollapsedLanes: normalizeKanbanCollapsedLanes(p.kanbanCollapsedLanes), tasksListOrder: p.tasksListOrder === 'priority' ? 'priority' : 'note', @@ -2375,6 +2422,121 @@ function withFileTaskStatus(task: VaultTask, status: FileTaskStatus): VaultTask } } +/** The check-off edit for `task` when it is an open recurring task (#1036), + * computed on `body`; null for any other task, which toggles as usual. */ +function recurringCompletionFor( + task: VaultTask, + body: string, + skip = false +): RecurringCompletion | null { + if (task.kind === 'file' || !/@repeat:/i.test(task.rawText)) return null + const state = useStore.getState() + return completeRecurringTaskAtIndex(body, task.taskIndex, { + today: toIsoDateLocal(new Date()), + fallbackDue: task.dueInferred + ? task.due + : dailyNoteDueForPath(state.notes, state.vaultSettings, task.sourcePath), + skip + }) +} + +/** Write a task's note: through the open buffer when there is one (it merges + * with unsaved edits and autosaves), else straight to disk. */ +async function writeTaskNoteBody(path: string, nextBody: string): Promise { + const state = useStore.getState() + if (state.noteContents[path]) { + state.updateNoteBody(path, nextBody) + return true + } + try { + await window.zen.writeNote(path, nextBody) + return true + } catch (err) { + console.error('writeNote (recurring task) failed', err) + return false + } +} + +/** Re-read one note's inline tasks from `body`. A recurring check-off inserts + * lines, so every index after it moves; patching one row would be wrong. */ +function replaceInlineTasksForNote(task: VaultTask, body: string): void { + const fresh = parseTasksFromBody(body, { + path: task.sourcePath, + title: task.noteTitle, + folder: task.noteFolder + }) + useStore.setState((s) => ({ + vaultTasks: [ + ...s.vaultTasks.filter((t) => t.sourcePath !== task.sourcePath || t.kind === 'file'), + ...fresh + ] + })) +} + +function shortDayLabel(iso: string): string { + const [y, m, d] = iso.split('-').map(Number) + return new Date(y, m - 1, d).toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric' + }) +} + +/** "Next: Sun, Nov 8", with an Undo that restores the note as it was, as long + * as nothing else has changed it since. */ +function announceRecurrence( + task: VaultTask, + done: RecurringCompletion, + before: string, + skip: boolean +): void { + const label = `Next: ${shortDayLabel(done.nextDue)}` + useToastStore.getState().addToast( + skip ? `Skipped. ${label}` : label, + 'info', + { label: 'Undo', onClick: () => void undoRecurrence(task, done.body, before) }, + 6000 + ) +} + +const undoRecurrence = trackTaskWrite(async (task: VaultTask, written: string, before: string) => { + const path = task.sourcePath + let current: string + try { + current = useStore.getState().noteContents[path]?.body ?? (await window.zen.readNote(path)).body + } catch (err) { + console.error('readNote (undo recurring task) failed', err) + return + } + if (current !== written) { + useToastStore.getState().addToast('The note changed since, so nothing was undone.', 'info') + return + } + if (await writeTaskNoteBody(path, before)) replaceInlineTasksForNote(task, before) +}) + +/** Check off (or with `skip`, skip) a recurring task in its note: the next + * occurrence above it, the task itself a record. False when `task` is not an + * open recurring task, so the caller can toggle it as usual. */ +async function completeRecurringTaskInNote(task: VaultTask, skip: boolean): Promise { + if (task.kind === 'file' || task.checked || !/@repeat:/i.test(task.rawText)) return false + const path = task.sourcePath + let body: string + try { + body = useStore.getState().noteContents[path]?.body ?? (await window.zen.readNote(path)).body + } catch (err) { + console.error('readNote (recurring task) failed', err) + return false + } + const done = recurringCompletionFor(task, body, skip) + if (!done) return false + if (await writeTaskNoteBody(path, done.body)) { + replaceInlineTasksForNote(task, done.body) + announceRecurrence(task, done, body, skip) + } + return true +} + function applyTaskMutationsToTask(task: VaultTask, mutations: TaskMutation[]): VaultTask { let next = task for (const m of mutations) { @@ -2796,7 +2958,10 @@ function collectPrefs(s: { kanbanGroupBy: KanbanGroupBy kanbanCardSort: KanbanCardSort hideEmptyTasks: boolean + taskReminders: boolean + taskReminderTime: string kanbanWipLimits: Record + kanbanColumnColors: Record kanbanLanes: KanbanLaneBy kanbanCollapsedLanes: Record tasksListOrder: TasksListOrder @@ -2921,7 +3086,10 @@ function collectPrefs(s: { kanbanGroupBy: s.kanbanGroupBy, kanbanCardSort: s.kanbanCardSort, hideEmptyTasks: s.hideEmptyTasks, + taskReminders: s.taskReminders, + taskReminderTime: s.taskReminderTime, kanbanWipLimits: s.kanbanWipLimits, + kanbanColumnColors: s.kanbanColumnColors, kanbanLanes: s.kanbanLanes, kanbanCollapsedLanes: s.kanbanCollapsedLanes, tasksListOrder: s.tasksListOrder, @@ -3619,8 +3787,14 @@ interface Store { /** Tasks with no text stay off the Tasks views, the Calendar and Timeline * panels and Home. (#1026) */ hideEmptyTasks: boolean + /** Task reminders on (#1036). */ + taskReminders: boolean + /** `HH:MM` for reminders that name a day but no time (#1036). */ + taskReminderTime: string /** Per-column Kanban WIP limits, keyed `${groupBy}:${columnId}`. (#1025) */ kanbanWipLimits: Record + /** Per-column Kanban colours, keyed `${groupBy}:${columnId}`. (#1037) */ + kanbanColumnColors: Record /** The field that splits the board into lanes, or 'none'. (#1027) */ kanbanLanes: KanbanLaneBy /** Collapsed lanes per board and lane field. (#1027) */ @@ -3831,6 +4005,11 @@ interface Store { ) => Promise /** Create a database in the configured default databases location and open it. (#362) */ newDatabase: () => Promise + /** Create a Kanban (a database that opens on its Board, with To do / Doing + * / Done columns) under `folder`/`subpath` and open it. (#1038) */ + createKanban: (folder: NoteFolder, subpath?: string, isCurrent?: () => boolean) => Promise + /** Create a Kanban where new databases go and open it. (#1038) */ + newKanban: () => Promise /** Rename a database (its `.base` folder); rehomes the open grid tab. */ renameDatabase: (csvPath: string, newTitle: string, isCurrent?: () => boolean) => Promise /** Optimistically replace a database's rows and debounce-persist the CSV. */ @@ -3876,6 +4055,9 @@ interface Store { toggleTaskFromList: (task: VaultTask) => Promise /** Toggle a task's cancelled state (`[-]` inline, `status: cancelled` for a * file-task). Cancelled = intentionally abandoned, distinct from done. (#450) */ + /** Skip one occurrence of a recurring task (#1036): the record is written + * cancelled and the next occurrence goes above it. */ + skipTaskOccurrence: (task: VaultTask) => Promise cancelTaskFromList: (task: VaultTask) => Promise /** Toggle a task's in-progress state (`[/]` inline, `status: in-progress` for * a file-task). Still open work: it keeps its place in Today rather than @@ -3913,8 +4095,16 @@ interface Store { setKanbanGroupBy: (group: KanbanGroupBy) => void setKanbanCardSort: (sort: KanbanCardSort) => void setHideEmptyTasks: (hide: boolean) => void + setTaskReminders: (on: boolean) => void + setTaskReminderTime: (time: string) => void /** Set one column's WIP limit, or clear it with null. (#1025) */ setKanbanWipLimit: (group: KanbanGroupBy, columnId: string, limit: number | null) => void + /** Set one column's colour, or clear it with null. (#1037) */ + setKanbanColumnColor: ( + group: KanbanGroupBy, + columnId: string, + color: KanbanColumnColor | null + ) => void /** Split the board into lanes by a second field, or 'none'. (#1027) */ setKanbanLanes: (laneBy: KanbanLaneBy) => void /** The collapsed lanes of one board split by one field; empty clears. (#1027) */ @@ -6525,7 +6715,10 @@ export const useStore = create((set, get) => { kanbanGroupBy: loadPrefs().kanbanGroupBy, kanbanCardSort: loadPrefs().kanbanCardSort, hideEmptyTasks: loadPrefs().hideEmptyTasks, + taskReminders: loadPrefs().taskReminders, + taskReminderTime: loadPrefs().taskReminderTime, kanbanWipLimits: loadPrefs().kanbanWipLimits, + kanbanColumnColors: loadPrefs().kanbanColumnColors, kanbanLanes: loadPrefs().kanbanLanes, kanbanCollapsedLanes: loadPrefs().kanbanCollapsedLanes, tasksListOrder: loadPrefs().tasksListOrder, @@ -6993,6 +7186,21 @@ export const useStore = create((set, get) => { ) await get().createDatabase(folder, subpath) }, + createKanban: async (folder, subpath = '', hostIsCurrent) => { + await get().createDatabase(folder, subpath, KANBAN_DATABASE_TITLE, hostIsCurrent, { + seed: kanbanDatabaseSeed() + }) + }, + newKanban: async () => { + const s = get() + const settings = normalizeVaultSettings(s.vaultSettings) + const { folder, subpath } = resolveCreateLocation( + settings.databasesLocation, + s.activeNote, + settings + ) + await get().createKanban(folder, subpath) + }, newTaskFile: trackNoteWrite(null, async (opts) => { const title = ( await promptApp({ @@ -7573,7 +7781,10 @@ export const useStore = create((set, get) => { // Focus / open the note in the active pane. This replaces the Tasks // tab's content area with the note (the Tasks tab itself stays in the - // strip, so the user can hop back with a click). + // strip, so the user can hop back with a click). `openNoteInPane` keeps + // no history, so the jump is recorded here, as openNoteAtOffset does + // (#484): Ctrl+O and the phones' Back return to Tasks, not past it. + set(noteHistoryAfterJump(get(), task.sourcePath)) await get().openNoteInPane(state.activePaneId, task.sourcePath) if (!isCurrent() || get().selectedPath !== task.sourcePath) return // Make sure the folder view is sensible in case the sidebar is visible. @@ -7600,6 +7811,8 @@ export const useStore = create((set, get) => { }, toggleTaskFromList: trackTaskWrite(async (task) => { + // Checking off a recurring task writes its next occurrence (#1036). + if (!task.checked && (await completeRecurringTaskInNote(task, false))) return const state = get() const path = task.sourcePath const openBuffer = state.noteContents[path] @@ -7641,6 +7854,10 @@ export const useStore = create((set, get) => { })) }), + skipTaskOccurrence: trackTaskWrite(async (task) => { + await completeRecurringTaskInNote(task, true) + }), + cancelTaskFromList: trackTaskWrite(async (task) => { const path = task.sourcePath const openBuffer = get().noteContents[path] @@ -7747,6 +7964,7 @@ export const useStore = create((set, get) => { } let nextBody = body + let recurring: RecurringCompletion | null = null if (task.kind === 'file') { // Whole-note task: every field lives in frontmatter, so apply the whole // batch as one frontmatter rewrite rather than per-line edits. @@ -7755,10 +7973,14 @@ export const useStore = create((set, get) => { fileTaskMutationUpdates(mutations, toIsoDateLocal(new Date())) ) } else { + // Checking off goes last: a recurring task's check-off copies the + // line as the other mutations left it into its next occurrence. + let checkOff = false for (const m of mutations) { switch (m.kind) { case 'set-checked': - nextBody = setTaskCheckedAtIndex(nextBody, task.taskIndex, m.checked) + if (m.checked) checkOff = true + else nextBody = setTaskCheckedAtIndex(nextBody, task.taskIndex, false) break case 'set-waiting': nextBody = setTaskWaitingAtIndex(nextBody, task.taskIndex, m.waiting) @@ -7783,6 +8005,10 @@ export const useStore = create((set, get) => { break } } + if (checkOff) { + recurring = recurringCompletionFor(task, nextBody) + nextBody = recurring?.body ?? setTaskCheckedAtIndex(nextBody, task.taskIndex, true) + } } if (nextBody === body) { if (hasOptimisticChange) void get().rescanTasksForPath(path) @@ -7818,6 +8044,10 @@ export const useStore = create((set, get) => { }) } } + if (recurring) { + replaceInlineTasksForNote(task, nextBody) + announceRecurrence(task, recurring, body, false) + } }) })() inFlightTaskMutations.add(running) @@ -8077,6 +8307,14 @@ export const useStore = create((set, get) => { set({ hideEmptyTasks: hide }) savePrefs(collectPrefs(get())) }, + setTaskReminders: (on) => { + set({ taskReminders: on }) + savePrefs(collectPrefs(get())) + }, + setTaskReminderTime: (time) => { + set({ taskReminderTime: normalizeReminderTime(time) }) + savePrefs(collectPrefs(get())) + }, // Global and portable like the column titles, deliberately not a per-vault // view override: that would need the Go server's vault.json mirror to learn // it (the reasoning #889 gave for the card order). @@ -8088,6 +8326,15 @@ export const useStore = create((set, get) => { set({ kanbanWipLimits: normalizeKanbanWipLimits(next) }) savePrefs(collectPrefs(get())) }, + // Kept like the WIP limits: global and portable, not a per-vault override. + setKanbanColumnColor: (group, columnId, color) => { + const key = `${group}:${columnId}` + const next: Record = { ...get().kanbanColumnColors } + if (color === null) delete next[key] + else next[key] = color + set({ kanbanColumnColors: normalizeKanbanColumnColors(next) }) + savePrefs(collectPrefs(get())) + }, setKanbanLanes: (laneBy) => { set({ kanbanLanes: normalizeKanbanLanes(laneBy) }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index a5d8f7294..8acdb243c 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -65,6 +65,18 @@ } } +/* A coloured Kanban column (#1037): its top edge in the column's hue, the + border plus an inset line, so no column shifts by a pixel. The ring vars + keep Tailwind's ring-* drop highlight; the drop-target rule below wins + while a card hovers. */ +.task-kanban-column[data-kanban-column-color] { + border-top-color: rgb(var(--kanban-column-hue)); + box-shadow: + inset 0 1px 0 0 rgb(var(--kanban-column-hue)), + var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000); +} + .task-kanban-column.is-drop-target, .task-kanban-lane-cell.is-drop-target, .task-kanban-lane-header.is-drop-target { @@ -6391,6 +6403,23 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-purple)); background-color: rgb(var(--z-purple) / 0.12); } +/* #1036: the recurring-task fields wear their own colors; a value the field + cannot mean anything with draws dimmed. */ +.cm-wysiwyg .cm-editor .cm-task-repeat, +.prose-zen .zen-task-repeat { + color: rgb(var(--z-aqua)); + background-color: rgb(var(--z-aqua) / 0.14); +} +.cm-wysiwyg .cm-editor .cm-task-remind, +.prose-zen .zen-task-remind { + color: rgb(var(--z-yellow)); + background-color: rgb(var(--z-yellow) / 0.15); +} +.cm-wysiwyg .cm-editor .cm-task-meta-invalid, +.prose-zen .zen-task-meta-invalid { + opacity: 0.55; + text-decoration: underline dotted; +} /* #512: derived subtask progress on a parent task line. Neutral until every child is done, then it borrows the green the done state already owns. */ .cm-wysiwyg .cm-editor .cm-task-rollup, diff --git a/packages/app-core/src/tasks.ts b/packages/app-core/src/tasks.ts index 2da916733..585df6aa9 100644 --- a/packages/app-core/src/tasks.ts +++ b/packages/app-core/src/tasks.ts @@ -4,6 +4,7 @@ import type { VaultTask } from '@bridge-contract/tasks' import { useStore, type KanbanGroupBy } from './store' import { filterTasksForDisplay, inferDailyTaskDueDates, parseTasksFromBody } from '@shared/tasks' import { TASK_LINE_RE } from '@shared/tasklists' +import { repeatRuleOfLine } from '@shared/task-recurrence' import { computeTasksRender } from './lib/tasks-filter' import { buildDailyNoteDateByPath } from './lib/vault-layout' import { dropMutationsFor } from './lib/task-column-mutations' @@ -149,6 +150,9 @@ export async function setTaskChecked(id: string, raw: string, checked: boolean): // The writer reports nothing when it declines or fails; read back. const written = await bodyToEdit(path) + // A recurring task checked off moves down a line under its next occurrence + // (#1036), so the line at its index is the new open one: any write is it. + if (checked && repeatRuleOfLine(task.rawText)) return written != null && written !== body ? 'applied' : 'retry' const now = written == null ? undefined : parseTasksFromBody(written, ctx)[task.taskIndex] return now && lineShape(now.rawText) === shape && now.checked === checked ? 'applied' : 'retry' } diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index fb756ed15..815c1941c 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index e5f3b918e..5cab010e3 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -106,7 +106,10 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanGroupBy', 'kanbanCardSort', 'hideEmptyTasks', + 'taskReminders', + 'taskReminderTime', 'kanbanWipLimits', + 'kanbanColumnColors', 'kanbanLanes', 'tasksListOrder', 'timelineRange', diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index d5bda6bf3..48ffdf0e2 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -87,7 +87,7 @@ import type { WorkflowUndoResult, WriteWorkflowInput } from './workflows.js' -import type { VaultTask } from './tasks.js' +import type { TaskReminder, TaskReminderTarget, VaultTask } from './tasks.js' import type { DatabaseDoc, DatabaseSeed, @@ -551,6 +551,12 @@ export interface ZenBridge { hideWindowToTray?(): Promise /** This window's Cloud sync state for the tray; null when there is none. */ reportTraySyncStatus?(status: TraySyncStatus | null): void + /** The task reminders to fire (#1036), the whole schedule each time. + * Desktop-only: the main process shows them as system notifications, the + * window hidden in the tray included. */ + setTaskReminders?(reminders: TaskReminder[]): void + /** A reminder notification was clicked. Desktop-only. */ + onTaskReminderOpen?(cb: (target: TaskReminderTarget) => void): () => void /** Quit the app for real. Linux and Windows have no app menu, so with the * tray holding the window this is the way out besides the icon's menu. */ quitApp?(): void diff --git a/packages/bridge-contract/src/databases.ts b/packages/bridge-contract/src/databases.ts index f4ed5ad35..209f2df04 100644 --- a/packages/bridge-contract/src/databases.ts +++ b/packages/bridge-contract/src/databases.ts @@ -144,6 +144,17 @@ export interface DatabaseSeed { headers: string[] rows: string[][] columnWidths?: Array + /** + * Open on a Board grouped by the header named `groupBy`, which becomes a + * select field whose options are `columns` in order (New Kanban, #1038). + * A Table view of the same fields sits beside it. + */ + board?: DatabaseSeedBoard +} + +export interface DatabaseSeedBoard { + groupBy: string + columns: string[] } /** Cells are raw CSV strings keyed by DbField.id. */ diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 4772d859b..39ff23798 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -183,6 +183,8 @@ export const IPC = { APP_GET_KEEP_RUNNING_IN_TRAY: 'app:get-keep-running-in-tray', APP_SET_KEEP_RUNNING_IN_TRAY: 'app:set-keep-running-in-tray', APP_TRAY_SYNC_STATUS: 'app:tray-sync-status', + TASK_REMINDERS_SET: 'tasks:set-reminders', + TASK_REMINDER_OPEN: 'tasks:reminder-open', WINDOW_HIDE_TO_TRAY: 'window:hide-to-tray', APP_QUIT: 'app:quit', APP_READ_EXTERNAL_FILE: 'app:read-external-file', @@ -436,7 +438,7 @@ export interface FileLocationSetting { * the note you're viewing; `folder` → the `folder` subfolder of the primary * location. */ mode: FileLocationMode - /** Vault-relative subfolder used when `mode === 'folder'`, e.g. `assets/drawings`. */ + /** Subfolder of the primary notes area used when `mode === 'folder'`, e.g. `Drawings`. */ folder?: string } export const DEFAULT_FILE_LOCATION: FileLocationSetting = { mode: 'primary' } diff --git a/packages/bridge-contract/src/tasks.ts b/packages/bridge-contract/src/tasks.ts index 6f8ee8b5e..dd45d2a42 100644 --- a/packages/bridge-contract/src/tasks.ts +++ b/packages/bridge-contract/src/tasks.ts @@ -70,3 +70,24 @@ export interface VaultTask { /** ISO YYYY-MM-DD completion date (frontmatter `completedDate`). File-tasks. */ completedDate?: string } + +/** One reminder a host should fire for a task with `@remind:` (#1036). The + * app computes the schedule (`computeTaskReminders` in shared-domain) and + * hands the whole of it to the host, which only times and shows them. */ +export interface TaskReminder { + /** Stable for one reminder of one task at one time; the host's "already + * fired" ledger keys on it. */ + key: string + /** Epoch milliseconds, local wall-clock time. */ + fireAt: number + title: string + body: string + path: string + /** The task's index in its note when the schedule was computed. The note may + * have changed since, so a host matches on `title` too before trusting it. */ + taskIndex: number +} + +/** What a clicked reminder asks the app to open: one task, or with `null` + * (several reminders folded into one notification) the Tasks view. */ +export type TaskReminderTarget = Pick | null diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index c03a08ea8..8ae79b7a0 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 621ef4026..43020b111 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -172,7 +172,10 @@ export const PORTABLE_DEFAULTS: Record = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', hideEmptyTasks: true, + taskReminders: true, + taskReminderTime: '09:00', kanbanWipLimits: {}, + kanbanColumnColors: {}, kanbanLanes: 'none', tasksListOrder: 'note', timelineRange: 'two-weeks', diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index 30a307506..242b10089 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -31,6 +31,7 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "note.new.quick", title: "New Quick Note", category: "Note", keywords: "scratch capture jot" }, { id: "note.new.inbox", title: "New Note in Inbox", category: "Note", keywords: "create add write" }, { id: "database.new", title: "New Database", category: "Note", keywords: "database table csv records spreadsheet board kanban base" }, + { id: "database.new.kanban", title: "New Kanban", category: "Note", keywords: "kanban board columns cards todo doing done project database base" }, { id: "task.new", title: "New Task", category: "Note", keywords: "task todo checkbox tasknotes taskforge new add create file due priority" }, { id: "task.new.folder", title: "New Task in Folder…", category: "Note", keywords: "task todo new create folder project location directory organize where place" }, { id: "note.daily.today", title: "Open Today's Daily Note", category: "Note", keywords: "daily journal date today log" }, @@ -114,6 +115,8 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "task.cancel", title: "Cancel Task", category: "Editor", keywords: "cancel task abandon drop dropped scrap wontfix strike" }, { id: "editor.link-popover", title: "Open Link Popover", category: "Editor", keywords: "link popover hover preview edit create note wikilink peek floating in place" }, { id: "task.toggle-checkbox", title: "Toggle Checkbox", category: "Editor", keywords: "toggle checkbox task check uncheck line todo checklist convert done" }, + { id: "task.repeat", title: "Repeat Task…", category: "Editor", keywords: "repeat recurring recurrence every daily weekly monthly yearly task schedule" }, + { id: "task.remind", title: "Remind Task…", category: "Editor", keywords: "remind reminder notification notify alarm alert task due" }, { id: "editor.reflow-paragraph", title: "Reflow Paragraph", category: "Editor", keywords: "reflow join lines unwrap hard wrap word wrap format paragraph gq fill" }, { id: "database.create-record-pages", title: "Create Pages for All Records", category: "Database", keywords: "database base records rows pages notes link wikilink bulk all csv" }, { id: "database.columns", title: "Choose Database Columns", category: "Database", keywords: "database base table columns fields show hide visible choose pick view" }, diff --git a/packages/shared-domain/src/database-csv.ts b/packages/shared-domain/src/database-csv.ts index ffaa0414d..5362b950e 100644 --- a/packages/shared-domain/src/database-csv.ts +++ b/packages/shared-domain/src/database-csv.ts @@ -9,6 +9,7 @@ */ import type { DatabaseSeed, + DatabaseSeedBoard, DatabaseSidecar, DbField, DbRow, @@ -277,7 +278,8 @@ export interface InitialDatabaseContents { * typed, blank or duplicate headers renamed, an existing all-unique `id` * column adopted as the row id and a hidden one synthesized otherwise. Rows * are keyed by the resulting field ids; a row without an id gets one minted, - * as `parseRows` does on read. + * as `parseRows` does on read. A seed's `board` turns the column it names + * into a select of the board's columns and opens on a Board grouped by it. * * The seed crosses the bridge from the renderer, so its shape is normalized * here rather than trusted: cells and headers are coerced to strings and only @@ -328,6 +330,75 @@ export function initialDatabaseContents( return { id, cells } }) - const { views, activeViewId } = buildDefaultViews(fields, genId) + const groupField = boardGroupField(seed.board, headers, columnFields, idFieldId) + if (groupField) { + for (const row of rows) row.cells[groupField.id] = optionValue(row.cells[groupField.id]) + groupField.type = 'select' + groupField.options = boardOptionValues(seed.board!.columns, rows.map((r) => r.cells[groupField.id])).map( + (value) => ({ id: genId(), value }) + ) + } + const { views, activeViewId } = groupField + ? buildBoardViews(fields, groupField.id, genId) + : buildDefaultViews(fields, genId) return { sidecar: { version: 1, idFieldId, fields, views, activeViewId }, rows } } + +/** The title New Kanban gives the database it creates (#1038). */ +export const KANBAN_DATABASE_TITLE = 'Untitled Kanban' + +/** + * What New Kanban creates (#1038): a card title, a Status select whose + * options are the board's columns, and no cards yet. It is an ordinary + * database, so the phones, the server, the web app and `zn base` read it + * with nothing new to learn. + */ +export function kanbanDatabaseSeed(): DatabaseSeed { + return { + headers: ['Name', 'Status'], + rows: [], + board: { groupBy: 'Status', columns: ['To do', 'Doing', 'Done'] } + } +} + +/** The seed column a board groups by, when the seed asks for a board and + * names one of its own columns other than the row id. */ +function boardGroupField( + board: DatabaseSeedBoard | undefined, + headers: string[], + columnFields: DbField[], + idFieldId: string +): DbField | null { + if (!board || typeof board !== 'object' || typeof board.groupBy !== 'string') return null + const field = columnFields[headers.indexOf(board.groupBy)] + return field && field.id !== idFieldId ? field : null +} + +/** Option values may not contain commas (multiSelect's cell encoding), so + * they read as spaces, as an option added in the grid does. */ +function optionValue(raw: string): string { + return raw.trim().replace(/,/g, ' ') +} + +/** A board's columns in order, then any other value its cards already hold, + * each once. */ +function boardOptionValues(columns: unknown, cellValues: string[]): string[] { + const listed = Array.isArray(columns) ? columns.map((c) => optionValue(String(c ?? ''))) : [] + const values: string[] = [] + for (const value of [...listed, ...cellValues]) { + if (value && !values.includes(value)) values.push(value) + } + return values +} + +/** A Board grouped by `groupFieldId` to open on, and the default Table + * view beside it. */ +function buildBoardViews( + fields: DbField[], + groupFieldId: string, + genId: GenId +): { views: DbView[]; activeViewId: string } { + const id = genId() + const board: DbView = { id, name: 'Board', type: 'board', filters: [], sorts: [], groupByFieldId: groupFieldId } + return { views: [board, ...buildDefaultViews(fields, genId).views], activeViewId: id } +} diff --git a/packages/shared-domain/src/database-ops.test.ts b/packages/shared-domain/src/database-ops.test.ts index 71d70c626..0e97cba05 100644 --- a/packages/shared-domain/src/database-ops.test.ts +++ b/packages/shared-domain/src/database-ops.test.ts @@ -5,7 +5,7 @@ import { type DatabaseFileOps, type DatabaseVaultLayout } from './database-ops' -import { parseCsv } from './database-csv' +import { kanbanDatabaseSeed, KANBAN_DATABASE_TITLE, parseCsv } from './database-csv' import { resolveFolderPath } from './system-folder-paths' // The composition is transport-agnostic by design: these tests drive it over @@ -119,6 +119,19 @@ describe('createDatabaseOps', () => { expect(reopened.rows).toEqual(doc.rows) }) + it('writes a Kanban the phones, the server and the web app all reopen on its Board (#1038)', async () => { + const vault = memVault() + const ops = createDatabaseOps(vault.io) + const doc = await ops.createDatabase('inbox', 'Biology', KANBAN_DATABASE_TITLE, kanbanDatabaseSeed()) + expect(doc.path).toBe('inbox/Biology/Untitled Kanban.base/data.csv') + expect(vault.files.get(doc.path)).toBe('id,Name,Status\n') + const reopened = await ops.openDatabase(doc.path) + const board = reopened.views.find((v) => v.id === reopened.activeViewId)! + expect(board.type).toBe('board') + const status = reopened.fields.find((f) => f.id === board.groupByFieldId)! + expect(status.options?.map((o) => o.value)).toEqual(['To do', 'Doing', 'Done']) + }) + it('adopts a bare CSV: infers a schema, persists the sidecar, canonicalizes ids', async () => { const vault = memVault() vault.files.set('inbox/Books.base/data.csv', 'Title,Rating\nDune,5\nNeuromancer,4\n') diff --git a/packages/shared-domain/src/databases.ts b/packages/shared-domain/src/databases.ts index aafefd3d1..5c8a73b90 100644 --- a/packages/shared-domain/src/databases.ts +++ b/packages/shared-domain/src/databases.ts @@ -11,6 +11,7 @@ export type { DbView, DatabaseSidecar, DatabaseSeed, + DatabaseSeedBoard, DbRow, DatabaseDoc, DatabaseSummary diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index e259d704b..4133cba9a 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -116,6 +116,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderTimeline", group: "vim", defaultBinding: "n", title: "Leader: toggle timeline" }, { id: "vim.leaderUnresolvedLinks", group: "vim", defaultBinding: "u", title: "Leader: unresolved links" }, { id: "vim.leaderFolderGallery", group: "vim", defaultBinding: "G", title: "Leader: folder gallery" }, + { id: "vim.leaderNewKanban", group: "vim", defaultBinding: "k", title: "Leader: new Kanban" }, { id: "vim.panePrefix", group: "vim", defaultBinding: "Ctrl+W", title: "Pane command prefix" }, { id: "vim.paneFocusLeft", group: "vim", defaultBinding: "h", title: "Pane: focus left" }, { id: "vim.paneFocusDown", group: "vim", defaultBinding: "j", title: "Pane: focus down" }, diff --git a/packages/shared-domain/src/task-recurrence.cases.json b/packages/shared-domain/src/task-recurrence.cases.json new file mode 100644 index 000000000..994d621db --- /dev/null +++ b/packages/shared-domain/src/task-recurrence.cases.json @@ -0,0 +1,169 @@ +{ + "about": "Check-off edits for recurring tasks (#1036). Run by task-recurrence.test.ts and, once mirrored, by the zn CLI's Go tests. `expected: null` means the task is not an open recurring one and the caller toggles it as usual.", + "cases": [ + { + "name": "weekly from its due date, the record drops @repeat and @remind", + "body": "- [ ] Water the plants @repeat:weekly due:2026-11-01 @remind:9am", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Water the plants @repeat:weekly due:2026-11-08 @remind:9am\n- [x] Water the plants due:2026-11-01" + }, + { + "name": "a late finish skips the missed occurrences", + "body": "- [ ] Journal @repeat:daily due:2026-10-27", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Journal @repeat:daily due:2026-11-02\n- [x] Journal due:2026-10-27" + }, + { + "name": "an early finish goes one interval past the due date", + "body": "- [ ] Report @repeat:2w due:2026-11-10", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Report @repeat:2w due:2026-11-24\n- [x] Report due:2026-11-10" + }, + { + "name": "no due date counts from today and writes one", + "body": "- [ ] Stretch @repeat:3d", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Stretch @repeat:3d due:2026-11-04\n- [x] Stretch" + }, + { + "name": "a daily note's date stands in for a missing due", + "body": "- [ ] Review inbox @repeat:weekly", + "taskIndex": 0, + "today": "2026-11-01", + "fallbackDue": "2026-10-30", + "expected": "- [ ] Review inbox @repeat:weekly due:2026-11-06\n- [x] Review inbox" + }, + { + "name": "frontmatter due beats the daily note's date", + "body": "---\ndue: 2026-10-31\n---\n- [ ] Pay invoice @repeat:monthly", + "taskIndex": 0, + "today": "2026-11-01", + "fallbackDue": "2026-10-01", + "expected": "---\ndue: 2026-10-31\n---\n- [ ] Pay invoice @repeat:monthly due:2026-11-30\n- [x] Pay invoice" + }, + { + "name": "monthly clamps to the month's last day", + "body": "- [ ] Rent @repeat:monthly due:2027-01-31", + "taskIndex": 0, + "today": "2027-01-31", + "expected": "- [ ] Rent @repeat:monthly due:2027-02-28\n- [x] Rent due:2027-01-31" + }, + { + "name": "yearly across a leap day", + "body": "- [ ] Renew passport @repeat:yearly due:2028-02-29", + "taskIndex": 0, + "today": "2028-02-29", + "expected": "- [ ] Renew passport @repeat:yearly due:2029-02-28\n- [x] Renew passport due:2028-02-29" + }, + { + "name": "weekday lists pick the next listed day", + "body": "- [ ] Gym @repeat:mon/thu due:2026-11-02", + "taskIndex": 0, + "today": "2026-11-02", + "expected": "- [ ] Gym @repeat:mon/thu due:2026-11-05\n- [x] Gym due:2026-11-02" + }, + { + "name": "weekdays skip the weekend", + "body": "- [ ] Stand-up @repeat:weekdays due:2026-11-06", + "taskIndex": 0, + "today": "2026-11-06", + "expected": "- [ ] Stand-up @repeat:weekdays due:2026-11-09\n- [x] Stand-up due:2026-11-06" + }, + { + "name": "after- counts from the day it is finished", + "body": "- [ ] Water the fern @repeat:after-3d due:2026-10-20", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Water the fern @repeat:after-3d due:2026-11-04\n- [x] Water the fern due:2026-10-20" + }, + { + "name": "the subtree repeats unticked; the record keeps its ticks and cancels what was left open", + "body": "## Chores\n- [ ] Weekly review @repeat:fri due:2026-10-30\n - [x] Inbox zero\n - [ ] Plan the week\n - [/] Draft the update\n - [ ] Nested open\n - [-] Calendar\n - [>] Moved [[Elsewhere]]\n notes stay\n- [ ] Other", + "taskIndex": 0, + "today": "2026-10-30", + "expected": "## Chores\n- [ ] Weekly review @repeat:fri due:2026-11-06\n - [ ] Inbox zero\n - [ ] Plan the week\n - [ ] Draft the update\n - [ ] Nested open\n - [ ] Calendar\n - [ ] Moved [[Elsewhere]]\n notes stay\n- [x] Weekly review due:2026-10-30\n - [x] Inbox zero\n - [-] Plan the week\n - [-] Draft the update\n - [-] Nested open\n - [-] Calendar\n - [>] Moved [[Elsewhere]]\n notes stay\n- [ ] Other" + }, + { + "name": "in progress finishes, the next one starts open; @waiting and @status do not carry", + "body": "- [/] Ship it !high @repeat:weekly due:2026-11-01 @status:doing @waiting #work", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Ship it !high @repeat:weekly due:2026-11-08 #work\n- [x] Ship it !high due:2026-11-01 @status:doing @waiting #work" + }, + { + "name": "skip writes a cancelled record", + "body": "- [ ] Water the plants @repeat:weekly due:2026-11-01\n - [ ] Check the soil", + "taskIndex": 0, + "today": "2026-11-01", + "skip": true, + "expected": "- [ ] Water the plants @repeat:weekly due:2026-11-08\n - [ ] Check the soil\n- [-] Water the plants due:2026-11-01\n - [-] Check the soil" + }, + { + "name": "numbered, quoted and indented tasks keep their prefix", + "body": "> 1. [ ] Quoted @repeat:daily due:2026-11-01", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "> 1. [ ] Quoted @repeat:daily due:2026-11-02\n> 1. [x] Quoted due:2026-11-01" + }, + { + "name": "the index counts like the parser, past fenced code", + "body": "```\n- [ ] not a task @repeat:daily\n```\n- [ ] first\n- [ ] second @repeat:daily due:2026-11-01", + "taskIndex": 1, + "today": "2026-11-01", + "expected": "```\n- [ ] not a task @repeat:daily\n```\n- [ ] first\n- [ ] second @repeat:daily due:2026-11-02\n- [x] second due:2026-11-01" + }, + { + "name": "a spaced due token is replaced as one", + "body": "- [ ] Spaced @repeat:daily due: 2026-11-01 tail", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Spaced @repeat:daily due:2026-11-02 tail\n- [x] Spaced due: 2026-11-01 tail" + }, + { + "name": "CRLF notes keep their endings", + "body": "- [ ] Win @repeat:daily due:2026-11-01\r\n - [ ] sub\r\n- [ ] after\r\n", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Win @repeat:daily due:2026-11-02\r\n - [ ] sub\r\n- [x] Win due:2026-11-01\r\n - [-] sub\r\n- [ ] after\r\n" + }, + { + "name": "the rule is case-insensitive", + "body": "- [ ] Loud @Repeat:WEEKLY due:2026-11-01", + "taskIndex": 0, + "today": "2026-11-01", + "expected": "- [ ] Loud @Repeat:WEEKLY due:2026-11-08\n- [x] Loud due:2026-11-01" + }, + { + "name": "an unknown rule is an ordinary task", + "body": "- [ ] Odd @repeat:fortnightly due:2026-11-01", + "taskIndex": 0, + "today": "2026-11-01", + "expected": null + }, + { + "name": "a done task does not repeat again", + "body": "- [x] Done @repeat:daily due:2026-11-01", + "taskIndex": 0, + "today": "2026-11-01", + "expected": null + }, + { + "name": "cancelled and forwarded tasks do not repeat", + "body": "- [-] Cancelled @repeat:daily\n- [>] Forwarded @repeat:daily", + "taskIndex": 1, + "today": "2026-11-01", + "expected": null + }, + { + "name": "a task without a rule is ordinary", + "body": "- [ ] Plain due:2026-11-01", + "taskIndex": 0, + "today": "2026-11-01", + "expected": null + } + ] +} diff --git a/packages/shared-domain/src/task-recurrence.test.ts b/packages/shared-domain/src/task-recurrence.test.ts new file mode 100644 index 000000000..5b2d6a019 --- /dev/null +++ b/packages/shared-domain/src/task-recurrence.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import cases from './task-recurrence.cases.json' +import { + completeRecurringTaskAtIndex, + describeRepeatRule, + nextRepeatDate, + parseRepeatRule, + planRecurringCompletion, + setRemindField, + setRepeatField +} from './task-recurrence' +import { parseTasksFromBody } from './tasks' + +interface Case { + name: string + body: string + taskIndex: number + today: string + fallbackDue?: string + skip?: boolean + expected: string | null +} + +describe('completeRecurringTaskAtIndex (the shared case table)', () => { + for (const c of (cases as { cases: Case[] }).cases) { + it(c.name, () => { + const result = completeRecurringTaskAtIndex(c.body, c.taskIndex, { + today: c.today, + fallbackDue: c.fallbackDue, + skip: c.skip + }) + expect(result?.body ?? null).toBe(c.expected) + }) + } + + it('parses back as one open occurrence above a closed record', () => { + const result = completeRecurringTaskAtIndex( + '- [ ] Water @repeat:weekly due:2026-11-01 @remind:9am', + 0, + { today: '2026-11-01' } + )! + const tasks = parseTasksFromBody(result.body, { path: 'a.md', title: 'a', folder: 'inbox' }) + expect(tasks.map((t) => [t.checked, t.due, t.fields?.repeat ?? null, t.fields?.remind ?? null])).toEqual([ + [false, '2026-11-08', 'weekly', '9am'], + [true, '2026-11-01', null, null] + ]) + expect(result.insertedLines).toBe(1) + expect(result.nextDue).toBe('2026-11-08') + }) + + it('leaves only the fresh copies open on the Tasks list', () => { + const body = '- [ ] Review @repeat:weekly due:2026-11-01\n - [x] Inbox\n - [ ] Plan' + const result = completeRecurringTaskAtIndex(body, 0, { today: '2026-11-01' })! + const open = parseTasksFromBody(result.body, { path: 'a.md', title: 'a', folder: 'inbox' }) + .filter((t) => !t.checked && !t.cancelled) + .map((t) => [t.content, t.lineNumber]) + expect(open).toEqual([ + ['Review', 0], + ['Inbox', 1], + ['Plan', 2] + ]) + }) +}) + +describe('parseRepeatRule', () => { + it('reads the named rules, intervals, day lists and after- rules', () => { + expect(parseRepeatRule('daily')).toEqual({ kind: 'interval', unit: 'day', every: 1, afterDone: false }) + expect(parseRepeatRule('Weekly')).toEqual({ kind: 'interval', unit: 'week', every: 1, afterDone: false }) + expect(parseRepeatRule('annually')).toEqual({ kind: 'interval', unit: 'year', every: 1, afterDone: false }) + expect(parseRepeatRule('6m')).toEqual({ kind: 'interval', unit: 'month', every: 6, afterDone: false }) + expect(parseRepeatRule('after-2w')).toEqual({ kind: 'interval', unit: 'week', every: 2, afterDone: true }) + expect(parseRepeatRule('weekdays')).toEqual({ kind: 'days', days: [1, 2, 3, 4, 5] }) + expect(parseRepeatRule('thu/mon/thu')).toEqual({ kind: 'days', days: [1, 4] }) + }) + + it('names no rule for anything else', () => { + for (const bad of ['', 'fortnightly', '0d', '2x', 'after-mon', 'mon/funday', '1000d']) { + expect(parseRepeatRule(bad)).toBeNull() + } + }) + + it('describes rules in a few words', () => { + const label = (v: string): string => describeRepeatRule(parseRepeatRule(v)!) + expect(label('weekly')).toBe('Weekly') + expect(label('2w')).toBe('Every 2 weeks') + expect(label('weekdays')).toBe('Weekdays') + expect(label('mon/thu')).toBe('Mon, Thu') + expect(label('after-1d')).toBe('1 day after done') + expect(label('after-3d')).toBe('3 days after done') + }) +}) + +describe('nextRepeatDate', () => { + const rule = (v: string) => parseRepeatRule(v)! + + it('counts months from the due date, so a late finish does not compound the clamp', () => { + expect(nextRepeatDate(rule('monthly'), '2027-01-31', '2027-03-05')).toBe('2027-03-31') + }) + + it('never lands on today or earlier', () => { + expect(nextRepeatDate(rule('weekly'), '2026-10-26', '2026-11-02')).toBe('2026-11-09') + expect(nextRepeatDate(rule('daily'), '2026-11-01', '2026-11-01')).toBe('2026-11-02') + expect(nextRepeatDate(rule('mon'), '2026-10-05', '2026-11-02')).toBe('2026-11-09') + }) + + it('crosses daylight saving without shifting a day', () => { + expect(nextRepeatDate(rule('daily'), '2026-03-07', '2026-03-07')).toBe('2026-03-08') + expect(nextRepeatDate(rule('weekly'), '2026-10-24', '2026-10-24')).toBe('2026-10-31') + }) +}) + +describe('planRecurringCompletion', () => { + it('reports the next occurrence and the record block for an editor line', () => { + const plan = planRecurringCompletion( + ['- [ ] A @repeat:daily', ' - [x] done child', ' - [ ] open child', 'after'], + 0, + { today: '2026-11-01' } + ) + expect(plan?.next).toEqual([ + '- [ ] A @repeat:daily due:2026-11-02', + ' - [ ] done child', + ' - [ ] open child' + ]) + expect(plan?.record).toBe('- [x] A') + expect(plan?.recordChildren).toEqual([' - [x] done child', ' - [-] open child']) + expect(plan?.replaces).toBe(3) + }) +}) + +describe('setRepeatField / setRemindField', () => { + it('replaces a rule where it stands, adds one at the end, and removes it', () => { + expect(setRepeatField('Water @repeat:daily due:2026-11-01', 'weekly')).toBe( + 'Water @repeat:weekly due:2026-11-01' + ) + expect(setRepeatField('Water due:2026-11-01', 'weekly')).toBe('Water due:2026-11-01 @repeat:weekly') + expect(setRepeatField('Water @repeat:daily due:2026-11-01', null)).toBe('Water due:2026-11-01') + expect(setRemindField('Pay rent', '1d')).toBe('Pay rent @remind:1d') + expect(setRemindField('Pay rent @remind:1d', null)).toBe('Pay rent') + }) +}) diff --git a/packages/shared-domain/src/task-recurrence.ts b/packages/shared-domain/src/task-recurrence.ts new file mode 100644 index 000000000..2c008b66c --- /dev/null +++ b/packages/shared-domain/src/task-recurrence.ts @@ -0,0 +1,361 @@ +/** + * Recurring tasks (#1036): the `@repeat:` field, the date it gives next, + * and the edit that checking off a recurring task makes. + * + * The rule rides the `@key:value` field grammar (#354), so the four synced + * task parsers already read it and none of them changed. What is new is only + * what a check-off WRITES, and that lives here so every surface that checks a + * task off (the Tasks views, the editor, Preview, MCP) makes the same edit: + * + * - [ ] Water the plants @repeat:weekly due:2026-11-01 + * becomes + * - [ ] Water the plants @repeat:weekly due:2026-11-08 + * - [x] Water the plants due:2026-11-01 + * + * The next occurrence goes ABOVE, where the task already stood, so the cursor + * and the list cursor stay on open work. The finished line is a record: it + * drops `@repeat` and `@remind`, the tokens that only mean something on open + * work, so unchecking it (or dragging a Done card back) never spawns a copy. + * A task's subtree repeats with it, every checkbox reset to open, and the + * record cancels (`[-]`) the subtasks it left open: they were not done, and + * left open they would stand beside their fresh copies on every Tasks list. + * + * Dates are whole days in `YYYY-MM-DD`, computed on UTC calendar fields so a + * daylight-saving change can never shift a date. The zn CLI mirrors this edit + * in Go; `task-recurrence.cases.json` holds the cases both test suites run. + */ +import { TASK_LINE_RE, leadingIndentWidth, taskBlockEnd, taskLineNumbers } from './tasklists' +import { noteFrontmatterDue } from './tasks' + +export type RepeatUnit = 'day' | 'week' | 'month' | 'year' + +export type RepeatRule = + /** Every N units, counted from the due date, or from the day the task is + * finished when `afterDone` (`@repeat:after-3d`). */ + | { kind: 'interval'; unit: RepeatUnit; every: number; afterDone: boolean } + /** On these weekdays (0 = Sunday, ascending): `mon/thu`, `weekdays`. */ + | { kind: 'days'; days: number[] } + +const NAMED: Record = { + daily: { unit: 'day', every: 1 }, + weekly: { unit: 'week', every: 1 }, + monthly: { unit: 'month', every: 1 }, + yearly: { unit: 'year', every: 1 }, + annually: { unit: 'year', every: 1 } +} +const UNIT_LETTERS: Record = { d: 'day', w: 'week', m: 'month', y: 'year' } +const INTERVAL_RE = /^(\d{1,3})([dwmy])$/ +const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] + +/** The rule a `@repeat:` value names, or null when it names none (an unknown + * rule leaves the task an ordinary one rather than guessing). */ +export function parseRepeatRule(raw: string | null | undefined): RepeatRule | null { + if (!raw) return null + let value = raw.trim().toLowerCase() + if (value === 'weekdays') return { kind: 'days', days: [1, 2, 3, 4, 5] } + let afterDone = false + if (value.startsWith('after-')) { + afterDone = true + value = value.slice('after-'.length) + } + const named = NAMED[value] + if (named) return { kind: 'interval', ...named, afterDone } + const interval = INTERVAL_RE.exec(value) + if (interval) { + const every = Number(interval[1]) + if (every < 1) return null + return { kind: 'interval', unit: UNIT_LETTERS[interval[2]], every, afterDone } + } + if (afterDone) return null + const days: number[] = [] + for (const part of value.split('/')) { + const day = DAY_NAMES.indexOf(part) + if (day < 0) return null + if (!days.includes(day)) days.push(day) + } + return days.length > 0 ? { kind: 'days', days: days.sort((a, b) => a - b) } : null +} + +const UNIT_WORDS: Record = { + day: ['day', 'days'], + week: ['week', 'weeks'], + month: ['month', 'months'], + year: ['year', 'years'] +} +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +/** A short, human label for a rule: "Weekly", "Every 2 weeks", "Mon, Thu", + * "3 days after done". For tooltips, toasts and the Repeat menu. */ +export function describeRepeatRule(rule: RepeatRule): string { + if (rule.kind === 'days') { + if (rule.days.join() === '1,2,3,4,5') return 'Weekdays' + return rule.days.map((d) => DAY_LABELS[d]).join(', ') + } + const [one, many] = UNIT_WORDS[rule.unit] + if (rule.afterDone) return `${rule.every} ${rule.every === 1 ? one : many} after done` + if (rule.every === 1) { + return { day: 'Daily', week: 'Weekly', month: 'Monthly', year: 'Yearly' }[rule.unit] + } + return `Every ${rule.every} ${many}` +} + +// --------------------------------------------------------------------------- +// Calendar dates +// --------------------------------------------------------------------------- + +const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})$/ +const DAY_MS = 86_400_000 + +function isoToUtc(iso: string): number | null { + const m = ISO_RE.exec(iso) + if (!m) return null + const t = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + return Number.isFinite(t) ? t : null +} + +function utcToIso(t: number): string { + const d = new Date(t) + const mm = String(d.getUTCMonth() + 1).padStart(2, '0') + const dd = String(d.getUTCDate()).padStart(2, '0') + return `${d.getUTCFullYear()}-${mm}-${dd}` +} + +/** `iso` moved by `n` days. */ +export function addDaysIso(iso: string, n: number): string { + const t = isoToUtc(iso) + return t == null ? iso : utcToIso(t + n * DAY_MS) +} + +/** `iso` moved by `n` months, the day clamped to the month's length: Jan 31 + * plus one month is Feb 28 (or 29). */ +function addMonthsIso(iso: string, n: number): string { + const t = isoToUtc(iso) + if (t == null) return iso + const d = new Date(t) + const month = d.getUTCMonth() + n + const year = d.getUTCFullYear() + Math.floor(month / 12) + const m = ((month % 12) + 12) % 12 + const last = new Date(Date.UTC(year, m + 1, 0)).getUTCDate() + return utcToIso(Date.UTC(year, m, Math.min(d.getUTCDate(), last))) +} + +function weekdayOf(iso: string): number { + const t = isoToUtc(iso) + return t == null ? 0 : new Date(t).getUTCDay() +} + +function addUnits(iso: string, n: number, unit: RepeatUnit): string { + if (unit === 'day') return addDaysIso(iso, n) + if (unit === 'week') return addDaysIso(iso, n * 7) + return addMonthsIso(iso, unit === 'year' ? n * 12 : n) +} + +/** + * The date of the occurrence after the one due on `base`, for a task finished + * on `today` (both `YYYY-MM-DD`). + * + * The answer is the first date of the rule's series after BOTH the due date + * and today, so a task finished late skips the occurrences it missed: a daily + * task done five days late comes back tomorrow, not as five overdue copies. + * (Obsidian Tasks steps one interval and can land in the past; we do not.) + * Months count from `base` each time, so one completion never compounds a + * month-end clamp. `afterDone` rules count from today instead. + */ +export function nextRepeatDate(rule: RepeatRule, base: string, today: string): string { + if (rule.kind === 'days') { + let date = addDaysIso(base > today ? base : today, 1) + for (let i = 0; i < 7 && !rule.days.includes(weekdayOf(date)); i++) { + date = addDaysIso(date, 1) + } + return date + } + if (rule.afterDone) return addUnits(today, rule.every, rule.unit) + if (rule.unit === 'day' || rule.unit === 'week') { + const step = rule.every * (rule.unit === 'week' ? 7 : 1) + const baseT = isoToUtc(base) + const todayT = isoToUtc(today) + if (baseT == null || todayT == null) return addDaysIso(base, step) + const behind = Math.round((todayT - baseT) / DAY_MS) + const k = behind < 0 ? 1 : Math.floor(behind / step) + 1 + return addDaysIso(base, k * step) + } + const months = rule.every * (rule.unit === 'year' ? 12 : 1) + for (let k = 1; k < 100_000; k++) { + const date = addMonthsIso(base, k * months) + if (date > today) return date + } + return addMonthsIso(base, months) +} + +// --------------------------------------------------------------------------- +// The check-off edit +// --------------------------------------------------------------------------- + +const FIELD_VALUE = '[\\p{L}\\d][\\p{L}\\d/_-]*' +const REPEAT_FIELD_RE = new RegExp(`(?:^|\\s)@repeat:(${FIELD_VALUE})`, 'iu') +const REPEAT_TOKEN_RE = new RegExp(`(^|\\s)@repeat:${FIELD_VALUE}`, 'giu') +const REMIND_TOKEN_RE = new RegExp(`(^|\\s)@remind:${FIELD_VALUE}`, 'giu') +const STATUS_TOKEN_RE = new RegExp(`(^|\\s)@status:${FIELD_VALUE}`, 'giu') +const WAITING_TOKEN_RE = /(^|\s)@waiting\b/gi +// The parser's `due:` token, spaced form included (#343). +const DUE_TOKEN_RE = /(^|\s)due:\s*(\S+)/i + +/** The `@repeat:` value written on a task line's text, if any. */ +export function repeatFieldOf(text: string): string | undefined { + return REPEAT_FIELD_RE.exec(text)?.[1]?.toLowerCase() +} + +/** The rule a task line repeats on, or null for an ordinary task. */ +export function repeatRuleOfLine(line: string): RepeatRule | null { + return parseRepeatRule(repeatFieldOf(line)) +} + +function tidy(text: string): string { + return text.replace(/[ \t]{2,}/g, ' ').replace(/\s+$/u, '') +} + +function bare(line: string): string { + return line.endsWith('\r') ? line.slice(0, -1) : line +} + +export interface RecurringCompletionOptions { + /** The day the task is finished, `YYYY-MM-DD` in the user's time zone. */ + today: string + /** The date a task without a `due:` falls back on: the note's frontmatter + * `due:`, else the daily note's own date when tasks take it. */ + fallbackDue?: string + /** "Skip this time": the record is written cancelled (`[-]`) instead of done. */ + skip?: boolean +} + +export interface RecurringLinePlan { + /** The new occurrence and its reset subtree, to go above the task. */ + next: string[] + /** The finished task's own line, rewritten as the record. */ + record: string + /** Its subtree as the record keeps it: the subtasks still open (`[ ]`, + * `[/]`) cancelled, everything else as it was. */ + recordChildren: string[] + /** How many lines the plan replaces: the task's line and its subtree. */ + replaces: number + nextDue: string + rule: RepeatRule +} + +/** + * Plan checking off the recurring task on line `at` of `lines` (bare lines, + * no `\r`): the occurrence that goes above it and what its own block becomes. + * Null when the line is not an OPEN task (`[ ]` or `[/]`) carrying a rule + * this module understands; the caller then toggles it like any other task. + */ +export function planRecurringCompletion( + lines: readonly string[], + at: number, + opts: RecurringCompletionOptions +): RecurringLinePlan | null { + const line = lines[at] + if (line == null) return null + const match = bare(line).match(TASK_LINE_RE) + if (!match || (match[2] !== ' ' && match[2] !== '/')) return null + if (!match[3].startsWith(']')) return null + const rest = match[3].slice(1) + const rule = parseRepeatRule(repeatFieldOf(rest)) + if (!rule) return null + + const written = DUE_TOKEN_RE.exec(rest)?.[2] + const writtenDue = written && isoToUtc(written) != null ? written : undefined + const base = writtenDue ?? opts.fallbackDue ?? opts.today + const nextDue = nextRepeatDate(rule, base, opts.today) + + // The occurrence keeps the text, priority, tags and every other field, in + // their order; `due:` is replaced where it stood. Its per-occurrence state + // does not carry: `@waiting` and the board's `@status:` start fresh. + let nextRest = DUE_TOKEN_RE.test(rest) + ? rest.replace(DUE_TOKEN_RE, `$1due:${nextDue}`) + : `${rest.replace(/\s+$/u, '')} due:${nextDue}` + nextRest = tidy(nextRest.replace(WAITING_TOKEN_RE, '$1').replace(STATUS_TOKEN_RE, '$1')) + const next = [`${match[1]} ]${nextRest}`] + + // The subtree comes along with every checkbox open again: a weekly review's + // checklist starts each week unticked. Notes under it copy verbatim. + // The record cancels the subtasks it left open, so only the fresh copies + // above read as live work; ticked, cancelled and forwarded ones stay. + const end = taskBlockEnd(lines.map(bare), at, leadingIndentWidth(bare(line))) + const recordChildren: string[] = [] + for (let i = at + 1; i < end; i++) { + const child = bare(lines[i]) + const childMatch = child.match(TASK_LINE_RE) + next.push(childMatch ? `${childMatch[1]} ${childMatch[3]}` : child) + const open = childMatch && (childMatch[2] === ' ' || childMatch[2] === '/') + recordChildren.push(open ? `${childMatch[1]}-${childMatch[3]}` : child) + } + + const recordRest = tidy(rest.replace(REPEAT_TOKEN_RE, '$1').replace(REMIND_TOKEN_RE, '$1')) + const record = `${match[1]}${opts.skip ? '-' : 'x'}]${recordRest}` + return { next, record, recordChildren, replaces: end - at, nextDue, rule } +} + +export interface RecurringCompletion { + body: string + nextDue: string + rule: RepeatRule + /** How many lines went in above the finished task. */ + insertedLines: number + /** How many of them are tasks: the finished task's own index moves down by + * this much. */ + insertedTasks: number +} + +/** + * Check off the recurring task at `taskIndex` (counted like the parser): + * the next occurrence goes above it and the task becomes its record. Null + * when that task is not an open recurring one. A Windows note keeps its + * `\r\n` endings, the inserted lines included. + */ +export function completeRecurringTaskAtIndex( + markdown: string, + taskIndex: number, + opts: RecurringCompletionOptions +): RecurringCompletion | null { + if (taskIndex < 0) return null + const lines = markdown.split('\n') + const at = taskLineNumbers(lines)[taskIndex] + if (at == null) return null + const fallbackDue = noteFrontmatterDue(markdown) ?? opts.fallbackDue + const plan = planRecurringCompletion(lines, at, { ...opts, fallbackDue }) + if (!plan) return null + const ending = lines[at].endsWith('\r') ? '\r' : '' + const endingOf = (i: number): string => (lines[i].endsWith('\r') ? '\r' : '') + lines.splice( + at, + plan.replaces, + ...plan.next.map((l) => l + ending), + plan.record + ending, + ...plan.recordChildren.map((l, k) => l + endingOf(at + 1 + k)) + ) + return { + body: lines.join('\n'), + nextDue: plan.nextDue, + rule: plan.rule, + insertedLines: plan.next.length, + insertedTasks: plan.next.filter((l) => TASK_LINE_RE.test(l)).length + } +} + +/** Set (or with null, remove) the `@repeat:` rule on a task line's text, + * replacing an existing rule where it stands. */ +export function setRepeatField(text: string, rule: string | null): string { + return setNamedField(text, 'repeat', rule) +} + +/** Set (or with null, remove) the `@remind:` field on a task line's text. */ +export function setRemindField(text: string, when: string | null): string { + return setNamedField(text, 'remind', when) +} + +function setNamedField(text: string, key: 'repeat' | 'remind', value: string | null): string { + const token = new RegExp(`(^|\\s)@${key}:${FIELD_VALUE}`, 'iu') + if (value == null) return tidy(text.replace(new RegExp(token.source, 'giu'), '$1')) + if (token.test(text)) return text.replace(token, `$1@${key}:${value}`) + return `${text.replace(/\s+$/u, '')} @${key}:${value}` +} diff --git a/packages/shared-domain/src/task-reminders.test.ts b/packages/shared-domain/src/task-reminders.test.ts new file mode 100644 index 000000000..7aeb678f0 --- /dev/null +++ b/packages/shared-domain/src/task-reminders.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + computeTaskReminders, + describeRemindSpec, + parseClockToken, + parseRemindSpec, + plainTaskTitle +} from './task-reminders' +import { inferDailyTaskDueDates, parseTasksFromBody } from './tasks' + +const ctx = { path: 'Home.md', title: 'Home', folder: 'inbox' as const } + +describe('parseClockToken', () => { + it('reads every written form', () => { + expect(parseClockToken('9am')).toEqual({ hour: 9, minute: 0 }) + expect(parseClockToken('930am')).toEqual({ hour: 9, minute: 30 }) + expect(parseClockToken('12am')).toEqual({ hour: 0, minute: 0 }) + expect(parseClockToken('12pm')).toEqual({ hour: 12, minute: 0 }) + expect(parseClockToken('1230pm')).toEqual({ hour: 12, minute: 30 }) + expect(parseClockToken('0930')).toEqual({ hour: 9, minute: 30 }) + expect(parseClockToken('1730')).toEqual({ hour: 17, minute: 30 }) + expect(parseClockToken('17h')).toEqual({ hour: 17, minute: 0 }) + expect(parseClockToken('17h30')).toEqual({ hour: 17, minute: 30 }) + }) + + it('refuses times that are not', () => { + for (const bad of ['13pm', '0am', '2460', '930', '9', '25h', '9:30am', 'noon']) { + expect(parseClockToken(bad)).toBeNull() + } + }) +}) + +describe('parseRemindSpec', () => { + it('reads a time, a day offset, and both', () => { + expect(parseRemindSpec('9am')).toEqual({ daysBefore: 0, time: { hour: 9, minute: 0 } }) + expect(parseRemindSpec('1d')).toEqual({ daysBefore: 1, time: null }) + expect(parseRemindSpec('2d-6pm')).toEqual({ daysBefore: 2, time: { hour: 18, minute: 0 } }) + expect(parseRemindSpec('2d-later')).toBeNull() + expect(parseRemindSpec('soon')).toBeNull() + }) + + it('describes them briefly', () => { + expect(describeRemindSpec(parseRemindSpec('9am')!)).toBe('9:00 AM') + expect(describeRemindSpec(parseRemindSpec('1730')!, '24h')).toBe('17:30') + expect(describeRemindSpec(parseRemindSpec('1d')!)).toBe('Day before') + expect(describeRemindSpec(parseRemindSpec('2d-6pm')!)).toBe('2 days before, 6:00 PM') + }) +}) + +describe('computeTaskReminders', () => { + const now = new Date(2026, 10, 1, 8, 0) + + it('schedules open tasks with a due date, in local time, earliest first', () => { + const tasks = parseTasksFromBody( + [ + '- [ ] Pay rent @remind:1d due:2026-11-03', + '- [ ] Water the [[Plants|plants]] @remind:9am due:2026-11-01', + '- [x] Done @remind:9am due:2026-11-01', + '- [-] Cancelled @remind:9am due:2026-11-01', + '- [ ] No date @remind:9am', + '- [ ] Bad @remind:whenever due:2026-11-01', + '- [ ] Waiting @waiting @remind:1730 due:2026-11-02' + ].join('\n'), + ctx + ) + const out = computeTaskReminders(tasks, { now, defaultTime: '08:30' }) + expect(out.map((r) => [r.title, new Date(r.fireAt).toString(), r.body])).toEqual([ + ['Water the plants', new Date(2026, 10, 1, 9, 0).toString(), 'Due today · Home'], + ['Pay rent', new Date(2026, 10, 2, 8, 30).toString(), 'Due tomorrow · Home'], + ['Waiting', new Date(2026, 10, 2, 17, 30).toString(), 'Due today · Home'] + ]) + expect(out[0].key).toBe(`Home.md\nWater the plants\n${out[0].fireAt}`) + }) + + it('keeps the last day for a host to catch up on, and drops what is further out', () => { + const tasks = parseTasksFromBody( + [ + '- [ ] Yesterday @remind:9am due:2026-10-31', + '- [ ] Last week @remind:9am due:2026-10-20', + '- [ ] Next month @remind:9am due:2026-12-20' + ].join('\n'), + ctx + ) + expect(computeTaskReminders(tasks, { now }).map((r) => r.title)).toEqual(['Yesterday']) + }) + + it('reminds a daily-note task on its note date', () => { + const tasks = inferDailyTaskDueDates( + parseTasksFromBody('- [ ] Call Mum @remind:6pm', { ...ctx, path: 'daily/2026-11-01.md' }), + new Map([['daily/2026-11-01.md', '2026-11-01']]) + ) + expect(computeTaskReminders(tasks, { now }).map((r) => new Date(r.fireAt).getHours())).toEqual([18]) + }) + + it('names a due date further out by its day', () => { + const tasks = parseTasksFromBody('- [ ] Trip @remind:3d due:2026-11-06', ctx) + expect(computeTaskReminders(tasks, { now })[0].body).toBe('Due Fri, Nov 6 · Home') + }) +}) + +describe('plainTaskTitle', () => { + it('drops markdown a notification would show literally', () => { + expect(plainTaskTitle('Read **[[Book|the book]]** and `code` [site](https://x.y)')).toBe( + 'Read the book and code site' + ) + }) +}) diff --git a/packages/shared-domain/src/task-reminders.ts b/packages/shared-domain/src/task-reminders.ts new file mode 100644 index 000000000..0a5baf38c --- /dev/null +++ b/packages/shared-domain/src/task-reminders.ts @@ -0,0 +1,182 @@ +/** + * Task reminders (#1036): the `@remind:` field and the schedule it gives. + * + * A reminder is relative to the task's due date, which is date-only, so the + * field says WHICH day (the due date, or N days before) and at WHAT time: + * + * @remind:9am 9:00 on the due date @remind:1d the day before, + * @remind:930am 9:30 on the due date at the default time + * @remind:1730 17:30 on the due date @remind:2d-6pm two days before, 18:00 + * @remind:17h30 17:30 on the due date + * + * Like `@repeat:`, it is a plain `@key:value` field, so the four synced task + * parsers already read it (the value is lower-cased) and none changed. A task + * with no due date, written or taken from its daily note, has nothing to be + * reminded about, and its reminder stays inert. + * + * `computeTaskReminders` is pure: the app runs it on the task index it keeps + * and hands the result to whatever host can show a notification. Nothing here + * knows about timers or windows. + */ +import type { TaskReminder } from '@zennotes/bridge-contract/tasks' +import type { VaultTask } from './tasks' + +export type { TaskReminder, TaskReminderTarget } from '@zennotes/bridge-contract/tasks' + +export interface ClockTime { + hour: number + minute: number +} + +export interface RemindSpec { + /** Days before the due date: 0 for the due date itself. */ + daysBefore: number + /** Null means the default reminder time (a setting). */ + time: ClockTime | null +} + +const DAYS_RE = /^(\d{1,2})d$/ +const DAYS_TIME_RE = /^(\d{1,2})d-(.+)$/ + +/** A clock time as `@remind:` writes it: `9am`, `930am`, `1230pm`, `0930`, + * `1730`, `17h`, `17h30`. Colons are not allowed in a field value, so none of + * the forms has one. */ +export function parseClockToken(raw: string): ClockTime | null { + const v = raw.trim().toLowerCase() + let m = /^(\d{1,2})(\d{2})?(am|pm)$/.exec(v) + if (m) { + const h = Number(m[1]) + const minute = m[2] ? Number(m[2]) : 0 + if (h < 1 || h > 12 || minute > 59) return null + const hour = (h % 12) + (m[3] === 'pm' ? 12 : 0) + return { hour, minute } + } + m = /^(\d{2})(\d{2})$/.exec(v) + if (m) return validClock(Number(m[1]), Number(m[2])) + m = /^(\d{1,2})h(\d{2})?$/.exec(v) + if (m) return validClock(Number(m[1]), m[2] ? Number(m[2]) : 0) + return null +} + +function validClock(hour: number, minute: number): ClockTime | null { + return hour <= 23 && minute <= 59 ? { hour, minute } : null +} + +/** Parse a settings-style `HH:MM`. Falls back to 09:00. */ +export function parseHhMm(raw: string | null | undefined): ClockTime { + const m = /^(\d{1,2}):(\d{2})$/.exec((raw ?? '').trim()) + return (m && validClock(Number(m[1]), Number(m[2]))) || { hour: 9, minute: 0 } +} + +/** The reminder a `@remind:` value names, or null when it names none. */ +export function parseRemindSpec(raw: string | null | undefined): RemindSpec | null { + if (!raw) return null + const v = raw.trim().toLowerCase() + let m = DAYS_RE.exec(v) + if (m) return { daysBefore: Number(m[1]), time: null } + m = DAYS_TIME_RE.exec(v) + if (m) { + const time = parseClockToken(m[2]) + return time ? { daysBefore: Number(m[1]), time } : null + } + const time = parseClockToken(v) + return time ? { daysBefore: 0, time } : null +} + +/** `9:00 AM` / `09:00`, in the app's clock format. */ +export function formatClock(time: ClockTime, format: '12h' | '24h' = '12h'): string { + const mm = String(time.minute).padStart(2, '0') + if (format === '24h') return `${String(time.hour).padStart(2, '0')}:${mm}` + const suffix = time.hour < 12 ? 'AM' : 'PM' + return `${time.hour % 12 || 12}:${mm} ${suffix}` +} + +/** A short label for a reminder: "9:00 AM", "Day before", "2 days before, 6:00 PM". */ +export function describeRemindSpec(spec: RemindSpec, format: '12h' | '24h' = '12h'): string { + const time = spec.time ? formatClock(spec.time, format) : null + if (spec.daysBefore === 0) return time ?? 'Morning of' + const day = spec.daysBefore === 1 ? 'Day before' : `${spec.daysBefore} days before` + return time ? `${day}, ${time}` : day +} + +export interface ComputeRemindersOptions { + now: Date + /** `HH:MM`, for reminders that name a day but no time. */ + defaultTime?: string + /** How far ahead to schedule. */ + horizonDays?: number + /** How far back to keep, so a host can fire what it missed while closed. */ + lookbackHours?: number +} + +/** Hosts cap what they accept; computing past it would only be dropped. */ +export const MAX_TASK_REMINDERS = 500 + +const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})$/ +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +/** Markdown a notification would show literally: wikilinks become their text, + * emphasis and code marks go. */ +export function plainTaskTitle(content: string): string { + return content + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2') + .replace(/\[\[([^\]]+)\]\]/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/(\*\*|__|~~|==|`)/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +function dueLabel(due: Date, fireDay: Date): string { + const days = Math.round((due.getTime() - fireDay.getTime()) / 86_400_000) + if (days === 0) return 'Due today' + if (days === 1) return 'Due tomorrow' + return `Due ${WEEKDAYS[due.getDay()]}, ${MONTHS[due.getMonth()]} ${due.getDate()}` +} + +/** + * Every reminder the open tasks ask for between `lookbackHours` ago and + * `horizonDays` ahead, earliest first. Done, cancelled and forwarded tasks + * never remind; `@waiting` ones do, since they keep their date. Pass tasks + * with daily-note dates already inferred so a daily note's task reminds on + * its note's day. + */ +export function computeTaskReminders( + tasks: readonly VaultTask[], + opts: ComputeRemindersOptions +): TaskReminder[] { + const now = opts.now.getTime() + const from = now - (opts.lookbackHours ?? 24) * 3_600_000 + const until = now + (opts.horizonDays ?? 7) * 86_400_000 + const fallback = parseHhMm(opts.defaultTime) + const out: TaskReminder[] = [] + for (const task of tasks) { + if (task.checked || task.cancelled || task.forwarded) continue + const spec = parseRemindSpec(task.fields?.remind) + const m = task.due ? ISO_RE.exec(task.due) : null + if (!spec || !m) continue + const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + const fireDay = new Date(due.getFullYear(), due.getMonth(), due.getDate() - spec.daysBefore) + const time = spec.time ?? fallback + const fireAt = new Date( + fireDay.getFullYear(), + fireDay.getMonth(), + fireDay.getDate(), + time.hour, + time.minute + ).getTime() + if (fireAt < from || fireAt > until) continue + const title = plainTaskTitle(task.content) || task.noteTitle + out.push({ + key: `${task.sourcePath}\n${title}\n${fireAt}`, + fireAt, + title, + body: `${dueLabel(due, fireDay)} · ${task.noteTitle}`, + path: task.sourcePath, + taskIndex: task.taskIndex + }) + } + out.sort((a, b) => a.fireAt - b.fireAt || a.key.localeCompare(b.key)) + return out.slice(0, MAX_TASK_REMINDERS) +} diff --git a/packages/shared-domain/src/tasklists.ts b/packages/shared-domain/src/tasklists.ts index 201e31191..ea3f687d3 100644 --- a/packages/shared-domain/src/tasklists.ts +++ b/packages/shared-domain/src/tasklists.ts @@ -73,7 +73,7 @@ function editTaskAtIndex( /** Line numbers (0-based) of every task line, indexed by task index. Counts * exactly like `editTaskAtIndex` / `parseTasksFromBody` (fence-aware) so the * positions line up with a `VaultTask.taskIndex`. */ -function taskLineNumbers(lines: string[]): number[] { +export function taskLineNumbers(lines: string[]): number[] { const out: number[] = [] let inFence = false let fenceMarker: string | null = null @@ -510,7 +510,7 @@ export function removeTaskAtIndex(markdown: string, taskIndex: number): string { return takeTaskLineAtIndex(markdown, taskIndex).body } -function leadingIndentWidth(line: string): number { +export function leadingIndentWidth(line: string): number { return line.match(/^[ \t]*/)?.[0].length ?? 0 } @@ -526,7 +526,7 @@ function leadingIndentWidth(line: string): number { * first blank line used to split loose lists: forwarding carried half a task's * children and stranded the rest live in the source (#611 review). */ -function taskBlockEnd(lines: string[], start: number, baseIndent: number): number { +export function taskBlockEnd(lines: string[], start: number, baseIndent: number): number { let end = start + 1 while (end < lines.length) { const next = lines[end] diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index 1ddb62777..0204475cb 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -122,6 +122,12 @@ function parseNoteDefaults(body: string): { defaults: NoteDefaults; fmEndOffset: return { defaults, fmEndOffset: m[0].length } } +/** The note-wide `due:` a frontmatter block gives every task without its own, + * the date `parseTasksFromBody` falls back on. */ +export function noteFrontmatterDue(body: string): string | undefined { + return parseNoteDefaults(body.replace(/\r\n/g, '\n')).defaults.due +} + // --------------------------------------------------------------------------- // Inline token extraction // --------------------------------------------------------------------------- diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 082bf21a6..695edbdf9 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.80.1", + "version": "2.81.0", "type": "module", "exports": { ".": "./src/index.ts"