From d4be20adbc120978665b9a49f782cc14f408aff9 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 02:55:23 +0200 Subject: [PATCH 1/8] feat(timestamps): capture timestamps as links on new installs New installs default the timestamp template to "- {{linktime}} ", so a captured timestamp is a playable obsidian://podnotes link. A missing data.json now decodes from DEFAULT_SETTINGS. Existing data keeps the template it has. PodNotes always writes the template, so data without one is older or hand-edited and falls back to the previous "- {{time}} " instead of the new default. Refs #345 --- obsidian-e2e.config.mjs | 2 +- src/constants.ts | 2 +- src/main.persistence.test.ts | 16 ++++ src/persistence/podNotesData.test.ts | 92 +++++++++++++++++++ src/persistence/podNotesData.ts | 3 +- src/persistence/settingsCodec.ts | 9 +- src/settingsMigrations.ts | 7 ++ src/utility/prepareTimestampInsertion.test.ts | 2 +- 8 files changed, 122 insertions(+), 11 deletions(-) diff --git a/obsidian-e2e.config.mjs b/obsidian-e2e.config.mjs index e77af3b3..62f22578 100644 --- a/obsidian-e2e.config.mjs +++ b/obsidian-e2e.config.mjs @@ -42,7 +42,7 @@ export default { skipBackwardLength: 15, skipForwardLength: 15, timestamp: { - template: "- {{time}} ", + template: "- {{linktime}} ", offset: 0, }, note: { diff --git a/src/constants.ts b/src/constants.ts index 5ac40069..19df3d81 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -78,7 +78,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { currentEpisode: undefined, timestamp: { - template: "- {{time}} ", + template: "- {{linktime}} ", offset: 0, }, diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index da9af010..f6b99c31 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -221,6 +221,22 @@ describe("PodNotes persistence integration", () => { ); }); + it("writes timestamp links as the capture default on a new install", async () => { + const saveData = vi.fn().mockResolvedValue(undefined); + const plugin = makePlugin(saveData); + Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(null) }); + + await plugin.loadSettings(); + + expect(saveData).toHaveBeenCalledTimes(1); + expect(saveData).toHaveBeenCalledWith( + expect.objectContaining({ + schemaVersion: 2, + timestamp: { template: "- {{linktime}} ", offset: 0 }, + }), + ); + }); + it("does not rewrite data that is already schema v2", async () => { const saveData = vi.fn().mockResolvedValue(undefined); const plugin = makePlugin(saveData); diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index a46a878f..b20bbf95 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -310,3 +310,95 @@ describe("PodNotes data schema", () => { ); }); }); + +/** An existing listener's data.json, apart from the timestamp setting under test. */ +const listenerData = { + savedFeeds: { + "Test Podcast": { + title: "Test Podcast", + url: "https://example.com/feed.xml", + artworkUrl: "https://example.com/artwork.jpg", + }, + }, + podNotes: {}, + defaultPlaybackRate: 1.5, + defaultVolume: 0.8, + hidePlayedEpisodes: false, + episodeListLimit: 10, + playedEpisodes: { + "Test Podcast::Episode 1": { + title: "Episode 1", + podcastName: "Test Podcast", + time: 1830, + duration: 3600, + finished: false, + }, + }, + favorites: { + icon: "lucide-star", + name: "Favorites", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + episodes: [], + }, + queue: { + icon: "list-ordered", + name: "Queue", + shouldEpisodeRemoveAfterPlay: true, + shouldRepeat: false, + episodes: [], + }, + autoQueue: true, + playlists: {}, + skipBackwardLength: 15, + skipForwardLength: 30, + note: { path: "Podcasts/{{title}}.md", template: "# {{title}}\n\n{{description}}" }, + download: { path: "Podcasts/{{podcast}}/{{title}}" }, + downloadedEpisodes: {}, + localFiles: { + icon: "folder", + name: "Local Files", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + episodes: [], + }, +}; + +describe("timestamp template (#345)", () => { + it.each([null, undefined])("captures timestamps as links on a new install (%o)", (data) => { + const decoded = decodePodNotesData(data); + + expect(decoded.settings.timestamp.template).toBe("- {{linktime}} "); + expect(decoded.warnings).toEqual([]); + expect(decoded.changed).toBe(true); + }); + + it.each(["- {{time}} ", "* {{time:mm:ss}} - "])( + "keeps the saved template %o through load and save", + (template) => { + const decoded = decodePodNotesData({ + ...listenerData, + schemaVersion: 2, + openAISecretId: "", + deepgramSecretId: "", + timestamp: { template, offset: 0 }, + }); + const persisted = encodePodNotesData(decoded.settings, decoded.unknownFields); + + expect(decoded.warnings).toEqual([]); + expect(decoded.changed).toBe(false); + expect(decoded.settings.timestamp).toEqual({ template, offset: 0 }); + expect(persisted.timestamp).toEqual({ template, offset: 0 }); + }, + ); + + it.each([ + ["no timestamp setting", listenerData, 0], + ["only a timestamp offset", { ...listenerData, timestamp: { offset: 5 } }, 5], + ])("keeps plain timestamps for older data with %s", (_case, data, offset) => { + const decoded = decodePodNotesData(data); + + expect(decoded.sourceVersion).toBe(0); + expect(decoded.settings.timestamp).toEqual({ template: "- {{time}} ", offset }); + }); +}); diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index d57f7120..fa647d87 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -72,7 +72,8 @@ export type PersistedPodNotesDataV2 = PersistedPodNotesSettings & * does not understand. */ export function decodePodNotesData(value: unknown): DecodedPodNotesData { - if (value === null || value === undefined) value = {}; + // No data.json yet is a new install, which starts from the current defaults. + if (value === null || value === undefined) value = structuredClone(DEFAULT_SETTINGS); if (!isPlainObject(value)) { throw new PodNotesDataError( diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 0378fd5f..298b6514 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -1,5 +1,6 @@ import { DEFAULT_SETTINGS } from "src/constants"; import { + LEGACY_TIMESTAMP_TEMPLATE, migrateDownloadPath, migrateFeedNoteSettings, migrateNoteSettings, @@ -135,13 +136,7 @@ function decodeTimestamp(value: unknown, warnings: Set): IPodNotesSettin const record = optionalRecord(value, warnings, "timestamp"); return { ...copySafeObject(record), - template: readString( - record, - "template", - DEFAULT_SETTINGS.timestamp.template, - warnings, - "timestamp", - ), + template: readString(record, "template", LEGACY_TIMESTAMP_TEMPLATE, warnings, "timestamp"), offset: readFiniteNumber( record, "offset", diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index f7182454..48af3fba 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -178,3 +178,10 @@ export function migrateSkipLength(value: unknown, fallback: number): number { const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; } + +/** + * The timestamp template before `{{linktime}}` became the default (#345). + * PodNotes always writes the template, so data without one is older or + * hand-edited and keeps the template it ran with. + */ +export const LEGACY_TIMESTAMP_TEMPLATE = "- {{time}} "; diff --git a/src/utility/prepareTimestampInsertion.test.ts b/src/utility/prepareTimestampInsertion.test.ts index 808f03ee..bd189983 100644 --- a/src/utility/prepareTimestampInsertion.test.ts +++ b/src/utility/prepareTimestampInsertion.test.ts @@ -127,7 +127,7 @@ describe("prepareTimestampForInsertion", () => { expect(result).toBe("[1:23](x) \\| note more"); }); - it("leaves the default '- {{time}} ' style capture unchanged in a table", () => { + it("leaves a plain '- {{time}} ' capture unchanged in a table", () => { const { getLine, lineCount } = fromLines(TABLE); const result = prepareTimestampForInsertion("- 0:01:23 ", { getLine, From 9c88ae6b58c80d39e4dda29d4508af04ad450c97 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 02:56:31 +0200 Subject: [PATCH 2/8] test: name it.each cases with %o, which Vitest formats Vitest has no %p token, so these cases all shared one literal "%p" name. Lists that hold an empty array use explicit argument rows, because Vitest titles a bare [] in a mixed list as "undefined". --- src/persistence/dateCodec.test.ts | 4 ++-- src/persistence/podNotesData.test.ts | 4 ++-- src/utility/formatDate.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/persistence/dateCodec.test.ts b/src/persistence/dateCodec.test.ts index 7a3e195e..5f12cf95 100644 --- a/src/persistence/dateCodec.test.ts +++ b/src/persistence/dateCodec.test.ts @@ -19,8 +19,8 @@ describe("dateCodec", () => { expect(encodeDate("2024-03-01T11:05:03+01:00")).toBe(iso); }); - it.each([undefined, null, "", " ", "not-a-date", 1_709_290_000_000, {}, []])( - "rejects invalid or unsupported input %p without throwing", + it.each([[undefined], [null], [""], [" "], ["not-a-date"], [1_709_290_000_000], [{}], [[]]])( + "rejects invalid or unsupported input %o without throwing", (value) => { expect(decodeDate(value)).toBeUndefined(); expect(encodeDate(value)).toBeUndefined(); diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index b20bbf95..3d8ac766 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -294,11 +294,11 @@ describe("PodNotes data schema", () => { expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); - it.each([[], "data", 42, true])("fails closed for malformed root %p", (value) => { + it.each([[[]], ["data"], [42], [true]])("fails closed for malformed root %o", (value) => { expect(() => decodePodNotesData(value)).toThrowError(PodNotesDataError); }); - it.each([0, -1, 1.5, "1", null])("fails closed for invalid schema version %p", (version) => { + it.each([0, -1, 1.5, "1", null])("fails closed for invalid schema version %o", (version) => { expect(() => decodePodNotesData({ schemaVersion: version })).toThrowError( /invalid schemaVersion/, ); diff --git a/src/utility/formatDate.test.ts b/src/utility/formatDate.test.ts index bd6ccbba..853c4bd7 100644 --- a/src/utility/formatDate.test.ts +++ b/src/utility/formatDate.test.ts @@ -21,7 +21,7 @@ describe("formatDate", () => { }); it.each([undefined, null, "", "not-a-date", new Date("invalid")])( - "returns an empty value for invalid input %p", + "returns an empty value for invalid input %o", (value) => { expect(formatDate(value, "YYYY-MM-DD")).toBe(""); }, From 2db1fe403611f4fefce072c17d3c534989139262 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 02:58:11 +0200 Subject: [PATCH 3/8] docs(verify): note the linked default timestamp format --- .claude/skills/verify/features/timestamps-uris.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/verify/features/timestamps-uris.md b/.claude/skills/verify/features/timestamps-uris.md index 4a807646..9ea7d4ec 100644 --- a/.claude/skills/verify/features/timestamps-uris.md +++ b/.claude/skills/verify/features/timestamps-uris.md @@ -39,7 +39,7 @@ Preconditions: ## Gotchas - Capture commands are editor commands. The palette lists them only while a Markdown editor is focused, so click into the note first. -- The default format `- {{time}} ` inserts plain text with no link. Set `{{linktime}}` before proving links. +- The default format is `- {{linktime}} `. A vault whose `data.json` holds a `{{time}}` format inserts plain text with no link, so check `timestamp.template` before proving links. - Obsidian writes the note about 2 s after the edit. Read after a short wait, or read the editor with `CLI eval code='app.workspace.getLeavesOfType("markdown")[0].view.editor.getValue()'`. - A clicked link leaves the app through `xdg-open` and comes back through Obsidian's single-instance handoff, which the Linux bridge sets up. Never run `xdg-open` yourself: it reaches the system Obsidian profile. - `UI uri` decodes values with `decodeURIComponent`, as Obsidian does, so `+` stays a literal plus. From 0774a0ed6cddfbd699f40f653b55234ddea4bf69 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 03:09:32 +0200 Subject: [PATCH 4/8] refactor(settings): keep the legacy timestamp template beside its decoder LEGACY_TIMESTAMP_TEMPLATE has one reader, decodeTimestamp, so it becomes a module-local const there. The settingsMigrations header now says where the helpers run (the persistence codec) and when a stored value is rewritten. --- src/persistence/settingsCodec.ts | 8 +++++++- src/settingsMigrations.ts | 19 +++++-------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 298b6514..33a6a0a2 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -1,6 +1,5 @@ import { DEFAULT_SETTINGS } from "src/constants"; import { - LEGACY_TIMESTAMP_TEMPLATE, migrateDownloadPath, migrateFeedNoteSettings, migrateNoteSettings, @@ -132,6 +131,13 @@ function decodeSecretId( return ""; } +/** + * The timestamp template before `{{linktime}}` became the default (#345). + * PodNotes always writes the template, so data without one is older or + * hand-edited and keeps the template it ran with. + */ +const LEGACY_TIMESTAMP_TEMPLATE = "- {{time}} "; + function decodeTimestamp(value: unknown, warnings: Set): IPodNotesSettings["timestamp"] { const record = optionalRecord(value, warnings, "timestamp"); return { diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index 48af3fba..0c245cc5 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -3,14 +3,12 @@ import { DIARIZATION_PROVIDERS, type DiarizationProviderId } from "./services/di import type { IPodNotesSettings } from "./types/IPodNotesSettings"; /** - * Settings migrations applied when settings are loaded from disk. + * Settings migrations that the persistence codec + * (`src/persistence/settingsCodec.ts`) applies while decoding `data.json`. * - * These run in `PodNotes.loadSettings` after the persisted `data.json` is merged - * over `DEFAULT_SETTINGS`. Changing a default alone is NOT enough for existing - * users: the plugin persists the whole settings object, so anyone who has used - * PodNotes already has the old value written to `data.json`, which overrides the - * new default on load. A migration rewrites that stored value in memory so the - * change actually reaches existing users (it is re-persisted on the next save). + * PodNotes persists the whole settings object, so a changed default reaches only + * new installs. Existing users keep their stored value unless a migration + * rewrites it, which we do only when the old stored value is broken. * * This module is pure (no Obsidian/UI dependencies) so the risky rewrite logic is * unit-testable. @@ -178,10 +176,3 @@ export function migrateSkipLength(value: unknown, fallback: number): number { const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; } - -/** - * The timestamp template before `{{linktime}}` became the default (#345). - * PodNotes always writes the template, so data without one is older or - * hand-edited and keeps the template it ran with. - */ -export const LEGACY_TIMESTAMP_TEMPLATE = "- {{time}} "; From ca6de1c5fbea0ba66c4367addc73c1484a593b70 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 03:10:12 +0200 Subject: [PATCH 5/8] test(settings): cover a non-string saved timestamp template Older data.json with a malformed timestamp.template decodes to the legacy plain template and reports the codec warning. Each row now states its exact warnings. --- src/persistence/podNotesData.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index 3d8ac766..f97b8cc1 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -393,12 +393,19 @@ describe("timestamp template (#345)", () => { ); it.each([ - ["no timestamp setting", listenerData, 0], - ["only a timestamp offset", { ...listenerData, timestamp: { offset: 5 } }, 5], - ])("keeps plain timestamps for older data with %s", (_case, data, offset) => { + ["no timestamp setting", listenerData, 0, []], + ["only a timestamp offset", { ...listenerData, timestamp: { offset: 5 } }, 5, []], + [ + "a non-string template", + { ...listenerData, timestamp: { template: 42, offset: 0 } }, + 0, + ["timestamp.template: expected a string"], + ], + ])("keeps plain timestamps for older data with %s", (_case, data, offset, warnings) => { const decoded = decodePodNotesData(data); expect(decoded.sourceVersion).toBe(0); expect(decoded.settings.timestamp).toEqual({ template: "- {{time}} ", offset }); + expect(decoded.warnings).toEqual(warnings); }); }); From 2de2df3c5cdc66f0c8129d1f81cc2844f1c6121a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 03:10:29 +0200 Subject: [PATCH 6/8] docs: tell existing users how to get timestamp links Existing installs keep their saved Capture timestamp format, which is often plain {{time}}. --- docs/docs/timestamps.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/timestamps.md b/docs/docs/timestamps.md index ea696045..dc77302a 100644 --- a/docs/docs/timestamps.md +++ b/docs/docs/timestamps.md @@ -7,6 +7,8 @@ With the default `{{linktime}}` format, each captured timestamp becomes a clicka ![Notes with clickable timestamp links](resources/timestamps.png) +If you used PodNotes before this default, it keeps your saved format. Set `Capture timestamp format` to `- {{linktime}} ` to get clickable links. + ## Settings For timestamps, you can use the following format strings: From 1e75525e996dfe617f859ce33ebff7f7e360ac0a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 03:10:29 +0200 Subject: [PATCH 7/8] docs(verify): prove the timestamp format setting with a distinct value The seeded default is already - {{linktime}} , so filling that value could never fail. Fill * {{linktime}} - instead and expect it in data.json and in the captured line. --- .claude/skills/verify/features/timestamps-uris.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/verify/features/timestamps-uris.md b/.claude/skills/verify/features/timestamps-uris.md index 9ea7d4ec..773c2064 100644 --- a/.claude/skills/verify/features/timestamps-uris.md +++ b/.claude/skills/verify/features/timestamps-uris.md @@ -28,9 +28,9 @@ Preconditions: - `PodNotes Fixture` is saved ([feeds.md](./feeds.md)) and `Fixture Episode 1` is unplayed. - A note exists and is open: `CLI create path='Verify/Timestamps.md' content='# Timestamps\n\n' open`. -- **Set a linked format.** Open Settings > PodNotes ([feeds.md](./feeds.md)), run `UI fill '.modal.mod-settings textarea[placeholder="- {{linktime}} "]' '- {{linktime}} '`, then close Settings. `data.json` `timestamp.template` is `- {{linktime}} `. +- **Set a linked format.** Open Settings > PodNotes ([feeds.md](./feeds.md)), run `UI fill '.modal.mod-settings textarea[placeholder="- {{linktime}} "]' '* {{linktime}} - '`, then close Settings. `data.json` `timestamp.template` is `* {{linktime}} - `. - **Play.** Start `Fixture Episode 1` ([player.md](./player.md)) and wait about 5 s. -- **Capture.** Run `UI click '.workspace-leaf-content[data-type="markdown"] .cm-content .cm-line:last-child'`, `UI key Ctrl+p`, `UI type .prompt-input 'Capture Timestamp'`, `UI click .suggestion-item --text 'Capture Timestamp'`. After about 2 s, `CLI read path='Verify/Timestamps.md'` has a line like `- [00:00:05](obsidian://podnotes?episodeName=Fixture%20Episode%201&url=...&time=5.73)`. +- **Capture.** Run `UI click '.workspace-leaf-content[data-type="markdown"] .cm-content .cm-line:last-child'`, `UI key Ctrl+p`, `UI type .prompt-input 'Capture Timestamp'`, `UI click .suggestion-item --text 'Capture Timestamp'`. After about 2 s, `CLI read path='Verify/Timestamps.md'` has a line like `* [00:00:05](obsidian://podnotes?episodeName=Fixture%20Episode%201&url=...&time=5.73) - `. - **Segment.** Run the same palette steps with `Capture Last 10 Seconds`. The line holds a range such as `00:00:05-00:00:15` and its URI has `time` and `endTime`. - **Open the link.** Let playback move on, run `CLI command id=markdown:toggle-preview` (Reading view), then `UI click '.markdown-reading-view a' --text '00:00:05'`. `api.podcast.title` is `Fixture Episode 1` and `api.currentTime` restarts from about 5.7 s. The instance log `/Library/Logs/obsidian.log` shows `Received callback URL` and `Processed URI`. - **Direct URI path.** Run `UI uri 'obsidian://podnotes?episodeName=Fixture%20Episode%201&url=&time=30'`. `api.currentTime` jumps to about 30 s. From 8bf8dabbeefb7c75f8c70fcd6cc3cb0de95c5956 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 03:16:25 +0200 Subject: [PATCH 8/8] docs: drop stale loadSettings notes and keep the space in the linktime hint --- docs/docs/timestamps.md | 2 +- src/settingsMigrations.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/docs/timestamps.md b/docs/docs/timestamps.md index dc77302a..c52611e2 100644 --- a/docs/docs/timestamps.md +++ b/docs/docs/timestamps.md @@ -7,7 +7,7 @@ With the default `{{linktime}}` format, each captured timestamp becomes a clicka ![Notes with clickable timestamp links](resources/timestamps.png) -If you used PodNotes before this default, it keeps your saved format. Set `Capture timestamp format` to `- {{linktime}} ` to get clickable links. +If you used PodNotes before this default, it keeps your saved format. Set `Capture timestamp format` to `- {{linktime}}` followed by a space to get clickable links. ## Settings For timestamps, you can use the following format strings: diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index 0c245cc5..9fe9d370 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -106,9 +106,8 @@ type StoredTranscript = { /** * Backfill the transcript settings with the diarization defaults (issue #168). * - * `loadSettings` replaces the whole persisted `transcript` object, so an existing - * user who has `{ path, template }` saved would otherwise get an `undefined` - * `transcript.diarization` and crash where the service reads it. This deep-merges + * An existing user who has only `{ path, template }` saved would otherwise get an + * `undefined` `transcript.diarization` and crash where the service reads it. This deep-merges * the new nested default while preserving any path/template the user configured, * and clamps a malformed/unknown provider back to the default so a hand-edited or * corrupted `data.json` can't select a provider that does not exist. @@ -149,8 +148,7 @@ type StoredFeedNote = { path?: string | null; template?: string | null }; /** * Backfill the feed-note settings so a partially-persisted `feedNote` is repaired - * on load (issue ST-08). `loadSettings` shallow-merges the persisted object over - * the default, so an old `data.json` holding only `{ path }` (or a hand-edited one + * on load (issue ST-08). An old `data.json` holding only `{ path }` (or a hand-edited one * with a `null` field) would leave `template` undefined and crash where * createFeedNote calls `template.replace(...)`. This coalesces any missing/null/ * non-string field back to the default while preserving a deliberately empty