Skip to content

Commit 209431d

Browse files
authored
feat(notes): ship a Bases-friendly default episode note template (#160) (#201)
* feat(notes): ship a Bases-friendly default episode note template (#160) The episode note path/template both defaulted to "" (empty), which left the "Create episode note" command disabled on a fresh install and gave new users no structured metadata. Ship sensible defaults: - note.path: PodNotes/{{podcast}}/{{title}}.md (matches the download convention) - note.template: Bases-friendly frontmatter (type/podcast-link/url/date/tags + user-fillable status/rating/favorite), with the raw title as the body H1 so YAML rules never apply to it. {{podcastlink}} ties each episode to its #163 feed note for Bases/Dataview rollups. Make the frontmatter bulletproof: NoteTemplateEngine now sanitizes the URL tags ({{url}}/{{stream}}/{{artwork}}/{{episodeurl}}/{{episodeartwork}}/{{feedurl}}/ {{feedartwork}}) by stripping " and \\ (lossless for valid URLs), mirroring FeedNoteTemplateEngine, so a quoted scalar like url: "{{url}}" stays valid. Migrate existing users without clobbering customizations: migrateNotePath / migrateNoteTemplate upgrade ONLY the exact legacy empty value (or absent), so any non-empty path/template the user configured is preserved. Mirrors the #183 migrateDownloadPath precedent and is wired into loadSettings. Update the e2e provisioning seed to match (keeps the seed/DEFAULT_SETTINGS drift test green and seeds the new default for verification), refresh the settings placeholder, and document the default + an example .base view in templates.md. Verified in the isolated worktree Obsidian vault: notes created via the real create-podcast-note command (incl. a hostile title/URL and a no-date episode) parse into clean frontmatter via Obsidian's properties parser. * fix(notes): harden URL-tag sanitization and correct Bases docs (#160 review) Address ultracode review findings on the #160 default template: - NoteTemplateEngine: guard {{url}}/{{stream}}/{{episodeurl}} with `?? ""` before sanitizeUrlForTemplate, matching the other URL tags. A corrupted/hand-edited data.json could surface a null/undefined on these typed-string fields, where the unguarded `.replace` would throw and abort note creation. Add a regression test covering a corrupted episode. - docs/templates.md: the default puts {{artwork}} in the body as a Markdown image, not a quoted frontmatter scalar; correct the YAML-safety bullet to say so. - docs/templates.md: quote the example .base filter condition ('type == "podcastEpisode"') to match Obsidian's canonical Bases syntax. * fix(notes): tighten note migration and Bases docs (#160 adversarial review) Address findings from the adversarial review of the #160 default template: - Migration: only upgrade the legacy empty note when the WHOLE note is the old default (path AND template both empty/absent). Replace the per-field migrateNotePath/migrateNoteTemplate with migrateNoteSettings so a user who configured one field and deliberately left the other empty (e.g. a custom path with an empty template to keep "Create episode note" disabled) is never silently re-enabled. Coalesce null fields so a corrupted data.json can't reach the path/template engines. Update tests to cover the preserve-partial case. - docs/templates.md: the example .base used a `sort:` view key, which is not part of the documented Obsidian Bases schema (sorting is view UI state; only `order` + `groupBy` are documented). Switch to `groupBy` (by listening status) and `note.`-prefixed properties, matching the canonical syntax. - Qualify the "always valid YAML" claim (docs + constants comment): the guarantee holds for the default and any ordinary feed-note path; a feed-note path with a literal quote/backslash in a folder segment would flow into {{podcastlink}}. * fix(notes): render URL tags verbatim, don't mutate them globally (#160 review) Codex review (P2): globally stripping "/\ from NoteTemplateEngine's URL tags corrupts {{url}} for local-file episodes, where episode.url is a wikilink from generateMarkdownLink (e.g. a file named Talk "A".mp3 would link to Talk A.mp3), and changes existing custom templates that use {{url}} outside YAML. Revert the URL-tag sanitization added earlier in this branch back to master's verbatim behavior. The default frontmatter stays valid YAML for the common case without mutating tag values: feed episode URLs are well-formed (no quotes), the {{podcastlink}} name is already sanitized, and ordinary local-file links contain no quotes. A URL or file name with a literal quote is the one case that would need adjusting, which is documented. sanitizeUrlForTemplate stays in use by FeedNoteTemplateEngine, where {{url}} is always a real website URL. Add a test guarding that {{url}}/{{episodeurl}} pass through verbatim (local-file wikilinks preserved), and update the docs/comment claims accordingly. * fix(notes): keep {{url}} out of the default frontmatter scalar (#160 review) Codex review (P2): after rendering URL tags verbatim, a local-file episode whose name contains a double quote (e.g. Talk "A".mp3, playable on macOS/Linux) makes {{url}} a wikilink with a quote, so url: "{{url}}" renders url: "[[Talk "A".mp3]]" and the whole frontmatter fails to parse, hiding every property from Bases. Move {{url}} (and it was already true for {{artwork}}) into the note body, where YAML rules don't apply, so the verbatim tag value can never break the frontmatter. The frontmatter now contains only values that can't carry a YAML-hostile character (type, the sanitized {{podcastlink}}, an ISO/empty date, tags, and the user-fillable status/rating/favorite). Feed-only users can add url: "{{url}}" back; documented. Update the example, the e2e seed, and the test (now exercises a quote-bearing local-file url to prove the frontmatter stays parseable).
1 parent 7b3e3c6 commit 209431d

8 files changed

Lines changed: 342 additions & 18 deletions

File tree

docs/docs/templates.md

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ PodNotes can create notes from templates. These templates can contain certain sy
33
To use templates, you can use the `Create episode note` Obsidian command (previously named `Create podcast note`).
44
This requires you to have defined a template for both the file path and note text.
55

6+
PodNotes ships a sensible default for both, so the command works out of the box on a fresh install. The default note template uses structured frontmatter properties that [Obsidian Bases](https://help.obsidian.md/bases) can sort, filter, and group on (see [Using the default template with Bases](#using-the-default-template-with-bases) below). You can customize either template under PodNotes settings at any time, and your customizations are always preserved across updates.
7+
68
PodNotes can also create a note for a whole podcast (the feed). See [Podcast feed notes](#podcast-feed-notes) below.
79

810
## File path
@@ -28,7 +30,7 @@ This template will be used to create the note text. You can use the following sy
2830
- You can use `{{content:> }}` to prepend each new line with a `>` (to put the entire content in a blockquote).
2931

3032
- `{{podcast}}`: The name of the podcast.
31-
- `{{url}}`: The URL of the podcast episode.
33+
- `{{url}}`: The URL of the podcast episode. For a local-file episode this is a link to the file rather than a web URL. Tag values are inserted verbatim, so when you place one in a quoted YAML property (e.g. `url: "{{url}}"`) it stays valid for well-formed feed URLs and ordinary file names (a URL or file name containing a literal `"` would need escaping).
3234
- `{{stream}}`: The direct URL of the episode's audio file — the RSS `<enclosure>` URL for podcast feeds, or the underlying audio source for Pocket Casts and local-file episodes. Handy for embedding the raw audio or linking to the source. An empty string is used in the rare case no audio URL is available. Available in episode note templates only.
3335
- `{{date}}`: The publish date of the podcast episode.
3436
- You can use `{{date:format}}` to specify a custom [Moment.js](https://momentjs.com) format. E.g. `{{date:YYYY-MM-DD}}`.
@@ -51,22 +53,62 @@ In an episode note, `{{url}}` and `{{artwork}}` always describe the **episode**.
5153
- `{{feedartwork}}`: The podcast's (feed) artwork. Falls back to the episode artwork if the feed isn't saved.
5254
- `{{podcastlink}}`: A ready-made wikilink to the podcast's [feed note](#podcast-feed-notes). It points at the same file the feed note is created at, so episodes and the feed note link up automatically. When the feed-note path has a folder it is path-qualified (e.g. `[[PodNotes/Podcasts/My Show|My Show]]`) so it can't resolve to an unrelated note that shares the basename; otherwise it's a plain `[[My Show]]`. (Avoid putting a `{{date}}` in the feed-note path, since the episode side can't reproduce the feed note's creation date.)
5355

54-
A Bases-friendly episode template that links back to the feed note:
56+
### Using the default template with Bases
57+
PodNotes ships this default episode note template, which links each episode back to its feed note and exposes structured frontmatter properties for [Obsidian Bases](https://help.obsidian.md/bases):
5558

5659
```
5760
---
5861
type: podcastEpisode
5962
podcast: "{{podcastlink}}"
60-
title: "{{title}}"
61-
image: "{{artwork}}"
62-
url: "{{url}}"
6363
date: {{date:YYYY-MM-DD}}
6464
tags:
6565
- podcastEpisode
66+
status:
67+
rating:
68+
favorite: false
6669
---
70+
# {{title}}
71+
72+
![]({{artwork}})
73+
74+
[Resume in PodNotes]({{episodelink}})
75+
76+
{{url}}
77+
6778
{{description}}
6879
```
6980

81+
The frontmatter is built so it stays **valid YAML** for every episode, because only values that can never contain a YAML-hostile character go into it:
82+
83+
- The full episode title goes in the body as the `# {{title}}` heading, where YAML rules don't apply (a raw title can contain `"` or `:`, which would break a frontmatter scalar).
84+
- `{{podcastlink}}` is quoted so its leading `[[` isn't read as a YAML flow sequence, and the linked name is sanitized. (If you customize *Feed note file path* to a folder containing a literal `"` or `\`, that character flows into the link verbatim — keep the feed-note path to ordinary path characters.)
85+
- `{{url}}` and `{{artwork}}` are kept in the **body** (a bare link and a Markdown image), not in the frontmatter. Tag values are inserted verbatim, and for a local-file episode `{{url}}` is a vault link whose name can contain a `"`; keeping it out of a quoted scalar means an awkward URL or file name can never break the note's properties. If your episodes all come from feeds (so `{{url}}` is always a well-formed web URL), you can add `url: "{{url}}"` back to the frontmatter.
86+
- `date:` is a bare `YYYY-MM-DD` (or empty/null when the feed has no publish date).
87+
- `status`, `rating`, and `favorite` are left for you to fill in — they give Bases columns to sort and filter on (e.g. mark an episode `favorite: true`, or set `status` to `to-listen`/`listening`/`listened`).
88+
89+
A starter Bases view (save as e.g. `Podcast Episodes.base`) that lists every episode note, grouped by listening status, with a column for each property:
90+
91+
```yaml
92+
filters:
93+
and:
94+
- 'type == "podcastEpisode"'
95+
views:
96+
- type: table
97+
name: Episodes
98+
order:
99+
- file.name
100+
- note.podcast
101+
- note.date
102+
- note.status
103+
- note.rating
104+
- note.favorite
105+
groupBy:
106+
property: note.status
107+
direction: ASC
108+
```
109+
110+
Open the base in Obsidian to sort, filter, or add views from the view options. Because `note.podcast` resolves to the linked [feed note](#podcast-feed-notes), you can also group by show or pivot from a feed note to all of its episodes.
111+
70112
## Podcast feed notes
71113
A *feed note* is a single parent note for an entire podcast (the feed), which episode notes can link to (great for [Obsidian Bases](https://help.obsidian.md/bases) / Dataview rollups).
72114

scripts/provision-obsidian-e2e-vault.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,23 @@ export const DEFAULT_PODNOTES_DATA = {
5252
offset: 0,
5353
},
5454
note: {
55-
path: "",
56-
template: "",
55+
path: "PodNotes/{{podcast}}/{{title}}.md",
56+
template:
57+
"---\n" +
58+
"type: podcastEpisode\n" +
59+
'podcast: "{{podcastlink}}"\n' +
60+
"date: {{date:YYYY-MM-DD}}\n" +
61+
"tags:\n" +
62+
" - podcastEpisode\n" +
63+
"status:\n" +
64+
"rating:\n" +
65+
"favorite: false\n" +
66+
"---\n" +
67+
"# {{title}}\n\n" +
68+
"![]({{artwork}})\n\n" +
69+
"[Resume in PodNotes]({{episodelink}})\n\n" +
70+
"{{url}}\n\n" +
71+
"{{description}}\n",
5772
},
5873
feedNote: {
5974
path: "PodNotes/Podcasts/{{podcast}}.md",

src/TemplateEngine.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { Episode } from "./types/Episode";
1212
import type { PodcastFeed } from "./types/PodcastFeed";
1313
import { downloadedEpisodes, plugin } from "./store";
14+
import { DEFAULT_SETTINGS } from "./constants";
1415

1516
// The illegal-character sanitizer is private; exercise it through
1617
// DownloadPathTemplateEngine, which applies it to {{title}} and {{podcast}}.
@@ -144,6 +145,108 @@ describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
144145
});
145146
});
146147

148+
describe("NoteTemplateEngine renders URL tags verbatim (#160 review)", () => {
149+
beforeEach(() => {
150+
plugin.set({
151+
settings: { feedNote: { path: "" }, savedFeeds: {} },
152+
} as never);
153+
});
154+
155+
it("does not mutate {{url}}/{{episodeurl}} — local-file wikilinks pass through", () => {
156+
// For local-file episodes episode.url is a wikilink, not a URL
157+
// (getContextMenuHandler stores generateMarkdownLink). The engine must not
158+
// strip characters from it, or the link would point at a different file.
159+
const localFile = {
160+
...demoEpisode,
161+
url: '[[Talk "A".mp3]]',
162+
} as Episode;
163+
expect(NoteTemplateEngine("{{url}}", localFile)).toBe('[[Talk "A".mp3]]');
164+
expect(NoteTemplateEngine("{{episodeurl}}", localFile)).toBe(
165+
'[[Talk "A".mp3]]',
166+
);
167+
});
168+
169+
it("renders a normal episode URL and artwork verbatim", () => {
170+
expect(NoteTemplateEngine("{{url}}|{{artwork}}", demoEpisode)).toBe(
171+
"https://example.com/ep1|https://example.com/ep1.png",
172+
);
173+
});
174+
});
175+
176+
describe("default note template renders valid frontmatter (#160)", () => {
177+
beforeEach(() => {
178+
plugin.set({
179+
settings: {
180+
feedNote: { path: "PodNotes/Podcasts/{{podcast}}.md" },
181+
savedFeeds: {},
182+
},
183+
} as never);
184+
downloadedEpisodes.set({});
185+
});
186+
187+
function frontmatterOf(rendered: string): string {
188+
const match = rendered.match(/^---\n([\s\S]*?)\n---\n/);
189+
expect(match).not.toBeNull();
190+
return (match as RegExpMatchArray)[1];
191+
}
192+
193+
it("keeps frontmatter valid even when title/url carry YAML-hostile characters", () => {
194+
// A local-file episode whose name contains a quote is the worst case: the
195+
// title carries quotes/colons and {{url}} is a wikilink containing a quote.
196+
// Both must stay in the BODY (never a quoted frontmatter scalar) so the
197+
// frontmatter always parses. See issue #160 review.
198+
const episode: Episode = {
199+
...demoEpisode,
200+
title: 'Why "AI": a deep dive: part 2',
201+
url: '[[Audio/Talk "A".mp3]]',
202+
podcastName: "My Show",
203+
};
204+
const rendered = NoteTemplateEngine(
205+
DEFAULT_SETTINGS.note.template,
206+
episode,
207+
);
208+
const frontmatter = frontmatterOf(rendered);
209+
const line = (key: string) =>
210+
frontmatter.split("\n").find((l) => l.startsWith(`${key}:`));
211+
212+
// The podcast link is quoted so its leading [[ isn't read as a flow sequence.
213+
expect(line("podcast")).toBe(
214+
'podcast: "[[PodNotes/Podcasts/My Show|My Show]]"',
215+
);
216+
// The url is NOT in the frontmatter (it could carry a quote for local files).
217+
expect(line("url")).toBeUndefined();
218+
// Every frontmatter line has balanced double-quotes.
219+
for (const l of frontmatter.split("\n")) {
220+
expect((l.match(/"/g) ?? []).length % 2).toBe(0);
221+
}
222+
// The raw title (quotes/colons) and the raw url (a quote-bearing wikilink)
223+
// live only in the body, where YAML rules don't apply.
224+
const body = rendered.slice(rendered.indexOf("\n---\n") + 5);
225+
expect(body).toContain('# Why "AI": a deep dive: part 2');
226+
expect(body).toContain('[[Audio/Talk "A".mp3]]');
227+
expect(frontmatter).not.toContain("deep dive");
228+
expect(frontmatter).not.toContain("Talk");
229+
});
230+
231+
it("renders an ISO date when present and an empty (null) date otherwise", () => {
232+
const withDate = NoteTemplateEngine(
233+
DEFAULT_SETTINGS.note.template,
234+
demoEpisode,
235+
);
236+
expect(
237+
withDate.split("\n").find((l) => l.startsWith("date:")),
238+
).toBe("date: 2024-01-01");
239+
240+
const noDate = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, {
241+
...demoEpisode,
242+
episodeDate: undefined,
243+
});
244+
const dateLine = noDate.split("\n").find((l) => l.startsWith("date:"));
245+
// Empty publish date renders as a null property, never a broken value.
246+
expect(dateLine?.replace(/\s+$/, "")).toBe("date:");
247+
});
248+
});
249+
147250
describe("FeedNoteTemplateEngine (#163)", () => {
148251
const feed: PodcastFeed = {
149252
title: "My Show: A Podcast",

src/constants.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,38 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
8282
},
8383

8484
note: {
85-
path: "",
86-
template: "",
85+
// Group episode notes under a per-podcast folder, matching the download
86+
// path convention (PodNotes/{{podcast}}/{{title}}). See issue #160.
87+
path: "PodNotes/{{podcast}}/{{title}}.md",
88+
// Bases-friendly frontmatter so new installs get queryable episode metadata
89+
// out of the box (issue #160). Every value here is guaranteed valid YAML
90+
// because none of them can contain a YAML-hostile character: {{podcastlink}}
91+
// is a wikilink whose name is sanitized (no quotes) and is quoted so its
92+
// leading "[[" can't be read as a flow sequence, and {{date:YYYY-MM-DD}} is
93+
// either an ISO date or empty (null). status/rating/favorite are left for the
94+
// user to fill and give Bases columns to sort and filter on. The raw
95+
// {{title}} (which may contain quotes/colons) and {{url}} (a feed URL, or for
96+
// a local file a vault link whose name may contain a quote) live in the BODY,
97+
// where YAML rules don't apply — keeping the verbatim, unescaped tag values
98+
// out of quoted frontmatter scalars so the frontmatter never fails to parse
99+
// (issue #160 review). {{podcastlink}} ties each episode to its feed note
100+
// (#163). See issue #160.
101+
template:
102+
"---\n" +
103+
"type: podcastEpisode\n" +
104+
'podcast: "{{podcastlink}}"\n' +
105+
"date: {{date:YYYY-MM-DD}}\n" +
106+
"tags:\n" +
107+
" - podcastEpisode\n" +
108+
"status:\n" +
109+
"rating:\n" +
110+
"favorite: false\n" +
111+
"---\n" +
112+
"# {{title}}\n\n" +
113+
"![]({{artwork}})\n\n" +
114+
"[Resume in PodNotes]({{episodelink}})\n\n" +
115+
"{{url}}\n\n" +
116+
"{{description}}\n",
87117
},
88118

89119
feedNote: {

src/main.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import { Plugin, type WorkspaceLeaf } from "obsidian";
1616
import { API } from "src/API/API";
1717
import type { IAPI } from "src/API/IAPI";
1818
import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants";
19-
import { migrateDownloadPath } from "src/settingsMigrations";
19+
import {
20+
migrateDownloadPath,
21+
migrateNoteSettings,
22+
} from "src/settingsMigrations";
2023
import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab";
2124
import { MainView } from "src/ui/PodcastView";
2225
import { QueueReorderModal } from "src/ui/QueueReorderModal";
@@ -495,6 +498,10 @@ export default class PodNotes extends Plugin implements IPodNotes {
495498
this.settings.episodeListLimit = sanitizeEpisodeListLimit(
496499
this.settings.episodeListLimit,
497500
);
501+
// Upgrade the legacy empty episode-note default to the Bases-friendly
502+
// default, preserving any path/template the user configured (#160). Returns
503+
// a fresh object, so DEFAULT_SETTINGS.note is never mutated.
504+
this.settings.note = migrateNoteSettings(loadedData?.note);
498505
}
499506

500507
async saveSettings() {

src/settingsMigrations.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DEFAULT_SETTINGS } from "./constants";
33
import {
44
LEGACY_EMPTY_DOWNLOAD_PATH,
55
migrateDownloadPath,
6+
migrateNoteSettings,
67
} from "./settingsMigrations";
78

89
describe("download path default (#183)", () => {
@@ -47,3 +48,78 @@ describe("migrateDownloadPath (#183)", () => {
4748
expect(migrateDownloadPath(once)).toBe(DEFAULT_SETTINGS.download.path);
4849
});
4950
});
51+
52+
describe("episode note defaults (#160)", () => {
53+
it("ships a non-empty path with per-episode and per-podcast tokens", () => {
54+
expect(DEFAULT_SETTINGS.note.path).not.toBe("");
55+
expect(DEFAULT_SETTINGS.note.path).toMatch(/\{\{\s*title\b/i);
56+
expect(DEFAULT_SETTINGS.note.path).toMatch(/\{\{\s*podcast\b/i);
57+
});
58+
59+
it("ships a non-empty Bases-friendly template with frontmatter", () => {
60+
const template = DEFAULT_SETTINGS.note.template;
61+
expect(template).not.toBe("");
62+
// Opens with a YAML frontmatter block...
63+
expect(template.startsWith("---\n")).toBe(true);
64+
// ...that closes before the body H1.
65+
expect(template.indexOf("\n---\n")).toBeLessThan(
66+
template.indexOf("# {{title}}"),
67+
);
68+
// Carries the structured properties Bases sorts/filters on.
69+
expect(template).toContain("type: podcastEpisode");
70+
expect(template).toMatch(/^tags:/m);
71+
});
72+
});
73+
74+
describe("migrateNoteSettings (#160)", () => {
75+
const DEFAULT_NOTE = {
76+
path: DEFAULT_SETTINGS.note.path,
77+
template: DEFAULT_SETTINGS.note.template,
78+
};
79+
80+
it("upgrades the legacy empty note (both fields empty) to the default", () => {
81+
expect(migrateNoteSettings({ path: "", template: "" })).toEqual(
82+
DEFAULT_NOTE,
83+
);
84+
});
85+
86+
it("treats an absent note (undefined/null/empty object) as the legacy default", () => {
87+
expect(migrateNoteSettings(undefined)).toEqual(DEFAULT_NOTE);
88+
expect(migrateNoteSettings(null)).toEqual(DEFAULT_NOTE);
89+
expect(migrateNoteSettings({})).toEqual(DEFAULT_NOTE);
90+
});
91+
92+
it("coalesces null/undefined fields and still upgrades a fully-absent note", () => {
93+
// A corrupted/hand-edited data.json could carry nulls; they must not reach
94+
// the path/template engines (null.replace would throw) and a wholly-empty
95+
// note still upgrades.
96+
expect(migrateNoteSettings({ path: null, template: null })).toEqual(
97+
DEFAULT_NOTE,
98+
);
99+
});
100+
101+
it("preserves a fully-configured note verbatim", () => {
102+
const custom = {
103+
path: "inputs/podcasts/{{podcast}} - {{title}}.md",
104+
template: "## {{title}}\n{{description}}",
105+
};
106+
expect(migrateNoteSettings(custom)).toEqual(custom);
107+
});
108+
109+
it("never overwrites a deliberately-empty field once the user configured the other", () => {
110+
// Custom path + empty template = note creation deliberately disabled; the
111+
// empty template must NOT be filled with the new default (would re-enable
112+
// the command). Symmetric for a custom template + empty path.
113+
expect(
114+
migrateNoteSettings({ path: "Custom/{{title}}.md", template: "" }),
115+
).toEqual({ path: "Custom/{{title}}.md", template: "" });
116+
expect(
117+
migrateNoteSettings({ path: "", template: "## {{title}}" }),
118+
).toEqual({ path: "", template: "## {{title}}" });
119+
});
120+
121+
it("is idempotent on the current default", () => {
122+
const once = migrateNoteSettings(DEFAULT_NOTE);
123+
expect(migrateNoteSettings(once)).toEqual(DEFAULT_NOTE);
124+
});
125+
});

0 commit comments

Comments
 (0)