From 13a0d2155ddeb250850b9d6ccb0561957afb9e4e Mon Sep 17 00:00:00 2001 From: Jason Long Date: Mon, 20 Jul 2026 20:40:34 -0400 Subject: [PATCH 1/2] fix: show backlinks for uncreated daily notes --- apps/desktop/src-tauri/src/db/tests.rs | 104 ++++++++++++++++++ .../migrations/0019_lazy_daily_backlinks.sql | 34 ++++++ crates/index-schema/src/lib.rs | 3 +- 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 crates/index-schema/migrations/0019_lazy_daily_backlinks.sql diff --git a/apps/desktop/src-tauri/src/db/tests.rs b/apps/desktop/src-tauri/src/db/tests.rs index b6150685e..a2770ccce 100644 --- a/apps/desktop/src-tauri/src/db/tests.rs +++ b/apps/desktop/src-tauri/src/db/tests.rs @@ -307,6 +307,110 @@ fn backlink_resolution_uses_daily_then_title_then_alias_precedence() { assert_eq!(rows[1]["target_path"], Value::from("daily/2026-07-10.md")); } +#[test] +fn backlinks_resolve_lazy_daily_dates_before_the_target_file_exists() { + let conn = migrated(); + let mut source = daily_note("daily/2026-07-16.md", "2026-07-16"); + source.links = vec![wiki("2026-07-27"), wiki("2026-02-31")]; + apply_note(&conn, &source).unwrap(); + + let unresolved = run_query( + &conn, + "SELECT target_path, target_raw FROM backlinks WHERE source_path = ?1", + &[Value::from("daily/2026-07-16.md")], + ) + .unwrap(); + assert_eq!( + unresolved.len(), + 1, + "impossible dates must not become daily routes" + ); + assert_eq!( + unresolved[0]["target_path"], + Value::from("daily/2026-07-27.md") + ); + assert_eq!(unresolved[0]["target_raw"], Value::from("2026-07-27")); + + // Until the daily exists, an ordinary title keeps the normal title tier. + apply_note(&conn, ¬e("notes/release-day.md", "2026-07-27", vec![])).unwrap(); + let titled = run_query( + &conn, + "SELECT target_path FROM backlinks WHERE source_path = ?1", + &[Value::from("daily/2026-07-16.md")], + ) + .unwrap(); + assert_eq!(titled.len(), 1); + assert_eq!( + titled[0]["target_path"], + Value::from("notes/release-day.md") + ); + + // Materializing the lazy daily immediately promotes the same link to the + // highest-precedence date tier without reindexing its source. + apply_note(&conn, &daily_note("daily/2026-07-27.md", "2026-07-27")).unwrap(); + let materialized = run_query( + &conn, + "SELECT target_path FROM backlinks WHERE source_path = ?1", + &[Value::from("daily/2026-07-16.md")], + ) + .unwrap(); + assert_eq!(materialized.len(), 1); + assert_eq!( + materialized[0]["target_path"], + Value::from("daily/2026-07-27.md") + ); +} + +#[test] +fn lazy_daily_backlink_migration_preserves_existing_projection_rows() { + let mut conn = open_in_memory().expect("open"); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + migrate_to(&mut conn, 18).expect("stage v18"); + apply_note( + &conn, + ¬e("notes/source.md", "Source", vec![wiki("2026-07-27")]), + ) + .unwrap(); + + let before = run_query( + &conn, + "SELECT target_path FROM backlinks WHERE source_path = 'notes/source.md'", + &[], + ) + .unwrap(); + assert!(before.is_empty()); + let counts_before: Vec = ["notes", "links"] + .iter() + .map(|table| { + conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() + }) + .collect(); + + migrate(&mut conn).expect("migrate to v19"); + + let counts_after: Vec = ["notes", "links"] + .iter() + .map(|table| { + conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() + }) + .collect(); + assert_eq!(counts_after, counts_before); + let rows = run_query( + &conn, + "SELECT target_path FROM backlinks WHERE source_path = 'notes/source.md'", + &[], + ) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["target_path"], Value::from("daily/2026-07-27.md")); +} + #[test] fn note_key_precedence_migration_preserves_existing_projection_rows() { let mut conn = open_in_memory().expect("open"); diff --git a/crates/index-schema/migrations/0019_lazy_daily_backlinks.sql b/crates/index-schema/migrations/0019_lazy_daily_backlinks.sql new file mode 100644 index 000000000..ef9e08581 --- /dev/null +++ b/crates/index-schema/migrations/0019_lazy_daily_backlinks.sql @@ -0,0 +1,34 @@ +-- A calendar-valid `[[YYYY-MM-DD]]` addresses its lazy daily route even before +-- that day's Markdown file exists. The previous view could only expose a +-- backlink after the target appeared in `note_keys`, while opening an empty day +-- deliberately does not create its file. Synthesize that one unresolved target +-- path until a real note claims the key; once one does, `note_keys` applies the +-- usual daily > title > alias precedence in the first branch. +DROP VIEW backlinks; + +CREATE VIEW backlinks AS + SELECT k.note_path AS target_path, l.source_path, l.kind, l.target_raw, l.alias, l.pos_from, l.pos_to + FROM links l JOIN note_keys k ON k.key = l.target_key + JOIN notes source ON source.path = l.source_path AND source.kind != 'template' + WHERE l.kind = 'wiki' + + UNION ALL + + SELECT + 'daily/' || l.target_key || '.md' AS target_path, + l.source_path, + l.kind, + l.target_raw, + l.alias, + l.pos_from, + l.pos_to + FROM links l + JOIN notes source ON source.path = l.source_path AND source.kind != 'template' + WHERE l.kind = 'wiki' + AND length(l.target_key) = 10 + AND substr(l.target_key, 5, 1) = '-' + AND substr(l.target_key, 8, 1) = '-' + AND date(l.target_key) = l.target_key + AND NOT EXISTS ( + SELECT 1 FROM note_keys k WHERE k.key = l.target_key + ); diff --git a/crates/index-schema/src/lib.rs b/crates/index-schema/src/lib.rs index 4a92875f1..56e046326 100644 --- a/crates/index-schema/src/lib.rs +++ b/crates/index-schema/src/lib.rs @@ -23,7 +23,7 @@ pub const INDEX_FILE: &str = "index.sqlite"; /// `user_version` after every migration has run. Read-only consumers compare /// this against `PRAGMA user_version` to detect an index written by a newer /// (or older) app than they were built for. -pub const LATEST_SCHEMA_VERSION: usize = 18; +pub const LATEST_SCHEMA_VERSION: usize = 19; /// The `index_meta` key holding the TS-owned projection version (the rows' /// derivation version, distinct from the schema version above). @@ -61,6 +61,7 @@ mod schema { M::up(include_str!("../migrations/0016_note_emails.sql")), M::up(include_str!("../migrations/0017_task_breadcrumbs.sql")), M::up(include_str!("../migrations/0018_note_key_precedence.sql")), + M::up(include_str!("../migrations/0019_lazy_daily_backlinks.sql")), ]) }); From dc5827da48b893a0fe21dcc4387379074841ba66 Mon Sep 17 00:00:00 2001 From: Jason Long Date: Tue, 21 Jul 2026 07:09:19 -0400 Subject: [PATCH 2/2] fix: create daily notes from date links --- apps/desktop/src-tauri/src/db/tests.rs | 104 ------------------ .../editor/use-editor-autocomplete.test.tsx | 85 ++++++++++++++ .../src/editor/use-editor-autocomplete.ts | 36 +++++- .../migrations/0019_lazy_daily_backlinks.sql | 34 ------ crates/index-schema/src/lib.rs | 3 +- packages/core/src/exports/platform.ts | 1 + packages/core/src/graph/create-note.test.ts | 38 +++++++ packages/core/src/graph/create-note.ts | 15 ++- 8 files changed, 173 insertions(+), 143 deletions(-) delete mode 100644 crates/index-schema/migrations/0019_lazy_daily_backlinks.sql diff --git a/apps/desktop/src-tauri/src/db/tests.rs b/apps/desktop/src-tauri/src/db/tests.rs index a2770ccce..b6150685e 100644 --- a/apps/desktop/src-tauri/src/db/tests.rs +++ b/apps/desktop/src-tauri/src/db/tests.rs @@ -307,110 +307,6 @@ fn backlink_resolution_uses_daily_then_title_then_alias_precedence() { assert_eq!(rows[1]["target_path"], Value::from("daily/2026-07-10.md")); } -#[test] -fn backlinks_resolve_lazy_daily_dates_before_the_target_file_exists() { - let conn = migrated(); - let mut source = daily_note("daily/2026-07-16.md", "2026-07-16"); - source.links = vec![wiki("2026-07-27"), wiki("2026-02-31")]; - apply_note(&conn, &source).unwrap(); - - let unresolved = run_query( - &conn, - "SELECT target_path, target_raw FROM backlinks WHERE source_path = ?1", - &[Value::from("daily/2026-07-16.md")], - ) - .unwrap(); - assert_eq!( - unresolved.len(), - 1, - "impossible dates must not become daily routes" - ); - assert_eq!( - unresolved[0]["target_path"], - Value::from("daily/2026-07-27.md") - ); - assert_eq!(unresolved[0]["target_raw"], Value::from("2026-07-27")); - - // Until the daily exists, an ordinary title keeps the normal title tier. - apply_note(&conn, ¬e("notes/release-day.md", "2026-07-27", vec![])).unwrap(); - let titled = run_query( - &conn, - "SELECT target_path FROM backlinks WHERE source_path = ?1", - &[Value::from("daily/2026-07-16.md")], - ) - .unwrap(); - assert_eq!(titled.len(), 1); - assert_eq!( - titled[0]["target_path"], - Value::from("notes/release-day.md") - ); - - // Materializing the lazy daily immediately promotes the same link to the - // highest-precedence date tier without reindexing its source. - apply_note(&conn, &daily_note("daily/2026-07-27.md", "2026-07-27")).unwrap(); - let materialized = run_query( - &conn, - "SELECT target_path FROM backlinks WHERE source_path = ?1", - &[Value::from("daily/2026-07-16.md")], - ) - .unwrap(); - assert_eq!(materialized.len(), 1); - assert_eq!( - materialized[0]["target_path"], - Value::from("daily/2026-07-27.md") - ); -} - -#[test] -fn lazy_daily_backlink_migration_preserves_existing_projection_rows() { - let mut conn = open_in_memory().expect("open"); - conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); - migrate_to(&mut conn, 18).expect("stage v18"); - apply_note( - &conn, - ¬e("notes/source.md", "Source", vec![wiki("2026-07-27")]), - ) - .unwrap(); - - let before = run_query( - &conn, - "SELECT target_path FROM backlinks WHERE source_path = 'notes/source.md'", - &[], - ) - .unwrap(); - assert!(before.is_empty()); - let counts_before: Vec = ["notes", "links"] - .iter() - .map(|table| { - conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| { - row.get(0) - }) - .unwrap() - }) - .collect(); - - migrate(&mut conn).expect("migrate to v19"); - - let counts_after: Vec = ["notes", "links"] - .iter() - .map(|table| { - conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| { - row.get(0) - }) - .unwrap() - }) - .collect(); - assert_eq!(counts_after, counts_before); - let rows = run_query( - &conn, - "SELECT target_path FROM backlinks WHERE source_path = 'notes/source.md'", - &[], - ) - .unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["target_path"], Value::from("daily/2026-07-27.md")); -} - #[test] fn note_key_precedence_migration_preserves_existing_projection_rows() { let mut conn = open_in_memory().expect("open"); diff --git a/apps/desktop/src/editor/use-editor-autocomplete.test.tsx b/apps/desktop/src/editor/use-editor-autocomplete.test.tsx index f6e120df1..a8c45f805 100644 --- a/apps/desktop/src/editor/use-editor-autocomplete.test.tsx +++ b/apps/desktop/src/editor/use-editor-autocomplete.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useEditorAutocomplete } from './use-editor-autocomplete' +const materializeDailyNote = vi.hoisted(() => vi.fn()) const resolveOrCreateNoteWithTitle = vi.hoisted(() => vi.fn()) const suggestWikiLinkTargets = vi.hoisted(() => vi.fn()) const operationFail = vi.hoisted(() => vi.fn()) @@ -13,6 +14,7 @@ vi.mock('@reflect/core', async (importOriginal) => ({ suggestWikiTargets: async () => [], suggestWikiLinkTargets, suggestTags: async () => [], + materializeDailyNote, resolveOrCreateNoteWithTitle, })) vi.mock('@/providers/graph-provider', () => ({ @@ -33,6 +35,7 @@ vi.mock('@/hooks/use-contacts-authorization', () => ({ vi.mock('@/lib/operations', () => ({ startOperation })) beforeEach(() => { + materializeDailyNote.mockReset().mockResolvedValue('daily/2026-07-27.md') resolveOrCreateNoteWithTitle.mockReset() suggestWikiLinkTargets.mockReset() suggestWikiLinkTargets.mockResolvedValue({ @@ -45,6 +48,88 @@ beforeEach(() => { }) describe('useEditorAutocomplete', () => { + it.each([ + { query: '2026-07-27', generated: undefined }, + { query: 'six days from now', generated: { phrase: 'Six days from now' } }, + ])('materializes a new daily when selecting $query', async ({ query, generated }) => { + suggestWikiLinkTargets.mockResolvedValue({ + suggestions: [ + { + target: '2026-07-27', + insertText: '2026-07-27', + title: '2026-07-27', + alias: null, + date: '2026-07-27', + path: null, + ...(generated === undefined ? {} : { generated }), + }, + ], + claimedTargetKeys: [], + queryReadsAsDate: true, + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch(query) + + act(() => { + items[0]!.onSelect?.() + }) + + await waitFor(() => + expect(materializeDailyNote).toHaveBeenCalledWith('2026-07-27', 7), + ) + }) + + it('does not try to recreate an existing daily suggestion', async () => { + suggestWikiLinkTargets.mockResolvedValue({ + suggestions: [ + { + target: '2026-07-27', + insertText: '2026-07-27', + title: '2026-07-27', + alias: null, + date: '2026-07-27', + path: 'daily/2026-07-27.md', + }, + ], + claimedTargetKeys: ['2026-07-27'], + queryReadsAsDate: true, + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch('2026-07-27') + + expect(items[0]?.onSelect).toBeUndefined() + expect(materializeDailyNote).not.toHaveBeenCalled() + }) + + it('surfaces a failed daily creation instead of leaving a silent dangling link', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + materializeDailyNote.mockRejectedValue(new Error('graph changed')) + suggestWikiLinkTargets.mockResolvedValue({ + suggestions: [ + { + target: '2026-07-27', + insertText: '2026-07-27', + title: '2026-07-27', + alias: null, + date: '2026-07-27', + path: null, + }, + ], + claimedTargetKeys: [], + queryReadsAsDate: true, + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch('2026-07-27') + + act(() => { + items[0]!.onSelect?.() + }) + + await waitFor(() => expect(operationFail).toHaveBeenCalledWith('graph changed')) + expect(startOperation).toHaveBeenCalledWith('Creating daily note') + consoleError.mockRestore() + }) + it('does not offer create when the exact query has an unaddressable claim', async () => { suggestWikiLinkTargets.mockResolvedValue({ suggestions: [], diff --git a/apps/desktop/src/editor/use-editor-autocomplete.ts b/apps/desktop/src/editor/use-editor-autocomplete.ts index e3d40b22b..f95d6846c 100644 --- a/apps/desktop/src/editor/use-editor-autocomplete.ts +++ b/apps/desktop/src/editor/use-editor-autocomplete.ts @@ -11,6 +11,7 @@ import { errorMessage, hasBridge, isContactsReadable, + materializeDailyNote, resolveOrCreateNoteWithTitle, suggestTags, suggestWikiLinkTargets, @@ -73,6 +74,19 @@ export function useEditorAutocomplete(): EditorAutocomplete { [generation], ) + const materializeDailyFromAutocomplete = useCallback( + (date: string): void => { + if (generation === null) { + return + } + void materializeDailyNote(date, generation).catch((error: unknown) => { + console.error('create-daily-from-autocomplete failed:', error) + startOperation('Creating daily note').fail(errorMessage(error)) + }) + }, + [generation], + ) + const onWikilinkSearch = useCallback( async (query: string): Promise => { if (!hasBridge() || graph === null) { @@ -135,10 +149,22 @@ export function useEditorAutocomplete(): EditorAutocomplete { } } const { title, alias, date, path, generated, insertText: target } = entry.suggestion + // Selecting a pathless date is the create action: materialize its empty + // daily file in the background so the inserted link resolves and gains + // a backlink without requiring the user to visit or edit that day. + const dateSelection = + date !== null && path === null + ? { onSelect: () => materializeDailyFromAutocomplete(date) } + : {} // A generated date leads with its phrase ("Next Friday"), resolved day // as the detail; everything else keeps the title/alias/daily form. if (generated !== undefined && date !== null) { - return { target, label: generated.phrase, detail: formatDayLabel(date, settings.dateFormat) } + return { + target, + label: generated.phrase, + detail: formatDayLabel(date, settings.dateFormat), + ...dateSelection, + } } // A rich title reads as its rendered form; the raw form stays the identity. const displayedTitle = displayNoteTitle(title) @@ -151,7 +177,12 @@ export function useEditorAutocomplete(): EditorAutocomplete { ? `${date} ยท new` : date : undefined - return { target, label, ...(detail !== undefined ? { detail } : {}) } + return { + target, + label, + ...(detail !== undefined ? { detail } : {}), + ...dateSelection, + } }) }, [ @@ -159,6 +190,7 @@ export function useEditorAutocomplete(): EditorAutocomplete { settings.dateFormat, settings.weekStartDay, resolveOrCreateFromAutocomplete, + materializeDailyFromAutocomplete, contactsInMenu, generation, ], diff --git a/crates/index-schema/migrations/0019_lazy_daily_backlinks.sql b/crates/index-schema/migrations/0019_lazy_daily_backlinks.sql deleted file mode 100644 index ef9e08581..000000000 --- a/crates/index-schema/migrations/0019_lazy_daily_backlinks.sql +++ /dev/null @@ -1,34 +0,0 @@ --- A calendar-valid `[[YYYY-MM-DD]]` addresses its lazy daily route even before --- that day's Markdown file exists. The previous view could only expose a --- backlink after the target appeared in `note_keys`, while opening an empty day --- deliberately does not create its file. Synthesize that one unresolved target --- path until a real note claims the key; once one does, `note_keys` applies the --- usual daily > title > alias precedence in the first branch. -DROP VIEW backlinks; - -CREATE VIEW backlinks AS - SELECT k.note_path AS target_path, l.source_path, l.kind, l.target_raw, l.alias, l.pos_from, l.pos_to - FROM links l JOIN note_keys k ON k.key = l.target_key - JOIN notes source ON source.path = l.source_path AND source.kind != 'template' - WHERE l.kind = 'wiki' - - UNION ALL - - SELECT - 'daily/' || l.target_key || '.md' AS target_path, - l.source_path, - l.kind, - l.target_raw, - l.alias, - l.pos_from, - l.pos_to - FROM links l - JOIN notes source ON source.path = l.source_path AND source.kind != 'template' - WHERE l.kind = 'wiki' - AND length(l.target_key) = 10 - AND substr(l.target_key, 5, 1) = '-' - AND substr(l.target_key, 8, 1) = '-' - AND date(l.target_key) = l.target_key - AND NOT EXISTS ( - SELECT 1 FROM note_keys k WHERE k.key = l.target_key - ); diff --git a/crates/index-schema/src/lib.rs b/crates/index-schema/src/lib.rs index 56e046326..4a92875f1 100644 --- a/crates/index-schema/src/lib.rs +++ b/crates/index-schema/src/lib.rs @@ -23,7 +23,7 @@ pub const INDEX_FILE: &str = "index.sqlite"; /// `user_version` after every migration has run. Read-only consumers compare /// this against `PRAGMA user_version` to detect an index written by a newer /// (or older) app than they were built for. -pub const LATEST_SCHEMA_VERSION: usize = 19; +pub const LATEST_SCHEMA_VERSION: usize = 18; /// The `index_meta` key holding the TS-owned projection version (the rows' /// derivation version, distinct from the schema version above). @@ -61,7 +61,6 @@ mod schema { M::up(include_str!("../migrations/0016_note_emails.sql")), M::up(include_str!("../migrations/0017_task_breadcrumbs.sql")), M::up(include_str!("../migrations/0018_note_key_precedence.sql")), - M::up(include_str!("../migrations/0019_lazy_daily_backlinks.sql")), ]) }); diff --git a/packages/core/src/exports/platform.ts b/packages/core/src/exports/platform.ts index 6570e8293..296129f14 100644 --- a/packages/core/src/exports/platform.ts +++ b/packages/core/src/exports/platform.ts @@ -141,6 +141,7 @@ export { untitledNotePath, isUntitledNotePath, createNoteWithTitle, + materializeDailyNote, resolveOrCreateNoteWithTitle, type ResolveOrCreateNoteResult, } from '../graph/create-note' diff --git a/packages/core/src/graph/create-note.test.ts b/packages/core/src/graph/create-note.test.ts index b9198e42a..e7bab88c4 100644 --- a/packages/core/src/graph/create-note.test.ts +++ b/packages/core/src/graph/create-note.test.ts @@ -3,6 +3,7 @@ import { setBridge } from '../ipc/bridge' import { createNoteWithTitle, isUntitledNotePath, + materializeDailyNote, resolveOrCreateNoteWithTitle, untitledNotePath, untitledNoteSeed, @@ -146,6 +147,43 @@ describe('createNoteWithTitle', () => { }) }) +describe('materializeDailyNote', () => { + it('atomically creates an empty daily file at the date path', async () => { + const invoke = bindBridge() + + await expect(materializeDailyNote('2026-07-27', 7)).resolves.toBe( + 'daily/2026-07-27.md', + ) + expect(invoke).toHaveBeenCalledWith('note_create', { + path: 'daily/2026-07-27.md', + contents: '', + generation: 7, + }) + }) + + it('does not replace an existing daily file', async () => { + const invoke = bindBridge({ files: { 'daily/2026-07-27.md': 'existing\n' } }) + + await expect(materializeDailyNote('2026-07-27', 7)).resolves.toBe( + 'daily/2026-07-27.md', + ) + expect(invoke).toHaveBeenCalledWith('note_create', { + path: 'daily/2026-07-27.md', + contents: '', + generation: 7, + }) + }) + + it('rejects impossible dates before attempting a write', async () => { + const invoke = bindBridge() + + await expect(materializeDailyNote('2026-02-31', 7)).rejects.toThrow( + 'dailyPath expects a valid calendar date', + ) + expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false) + }) +}) + describe('resolveOrCreateNoteWithTitle', () => { it('uses exact index resolution before reading the slug family', async () => { const invoke = bindBridge({ diff --git a/packages/core/src/graph/create-note.ts b/packages/core/src/graph/create-note.ts index 9107c68cd..6d7fb6dd3 100644 --- a/packages/core/src/graph/create-note.ts +++ b/packages/core/src/graph/create-note.ts @@ -2,7 +2,7 @@ import { ulid } from 'ulidx' import { upsertFrontmatter } from '../markdown/frontmatter' import { slugForTitle } from '../markdown/slug' import { createNoteIfAbsent } from './commands' -import { notePath } from './paths' +import { dailyPath, notePath } from './paths' import { resolveExistingWikiTarget, type ExistingWikiTargetResolution, @@ -81,6 +81,19 @@ export async function createNoteWithTitle( return claimed.path } +/** + * Materialize a daily note selected from wiki-link autocomplete without + * replacing one created concurrently. Daily notes have no title seed: their + * date path is their identity, and an empty file is the canonical new state. + * + * @returns The graph-relative daily-note path. + */ +export async function materializeDailyNote(date: string, generation: number): Promise { + const path = dailyPath(date) + await createNoteIfAbsent(path, '', generation) + return path +} + /** Far beyond any real graph's same-slug population; fail loud instead of spinning. */ const MAX_CREATE_ATTEMPTS = 1000