Skip to content

Commit a27d23d

Browse files
authored
feat(settings): import/export settings & templates (#180)
* feat(settings): import/export settings & templates Add an in-app "Settings & templates" import/export to the settings tab, alongside the existing OPML feed import/export (#162). Export writes a versioned JSON envelope of preferences, templates, saved feeds, and playlists. It excludes vault-specific runtime state (playback progress, downloaded-episode bookkeeping, the episode-to-note map, the current episode) and the OpenAI API key (opt-in toggle, with a plaintext warning). Export is create-only so it never clobbers an existing vault file. Import accepts a PodNotes export or a raw data.json, validates it (type/version, per-value types, prototype-pollution-safe), merges over the current settings (preserving runtime state), re-hydrates the live stores, and persists. A confirmation modal precedes the overwrite. Closes #162 * docs(settings): add settings import/export screenshot
1 parent 184188c commit a27d23d

5 files changed

Lines changed: 828 additions & 2 deletions

File tree

docs/docs/import_export.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,37 @@ To import podcasts, follow these steps:
1515
## Exporting
1616

1717
You can export your saved feeds to `opml` format.
18-
First designate a file path to save to (or use the default), and click _Export_.
18+
First designate a file path to save to (or use the default), and click _Export_.
19+
20+
## Settings & templates
21+
22+
Under the **Settings & templates** heading you can move your PodNotes
23+
configuration between vaults or back it up. This covers your preferences,
24+
note/timestamp/transcript templates, file paths, saved feeds, and playlists.
25+
26+
![Settings & templates import/export](resources/settings_import_export.png)
27+
28+
Playback progress, downloaded-episode bookkeeping, the currently-playing
29+
episode, and the episode-to-note mapping are **not** included, because they are
30+
specific to a single vault.
31+
32+
### Exporting settings
33+
34+
1. (Optional) Enable **Include OpenAI API key** if you also want your key in the
35+
file. The key is written in plaintext, so only do this for files you keep
36+
private — the file lives in your vault and may sync to other devices or be
37+
read by other plugins.
38+
2. Set a file name (or keep the default `PodNotes_Settings.json`).
39+
3. Click **Export**. The settings file is written to your vault.
40+
41+
### Importing settings
42+
43+
1. Click **Import** next to _Import settings_ and choose a settings file. Both a
44+
PodNotes export file and a raw `data.json` are accepted.
45+
2. Confirm the import. Your current preferences, templates, feeds, and playlists
46+
are replaced with the imported values; playback progress and downloads are
47+
kept.
48+
49+
A file exported by a newer version of PodNotes is rejected until you update the
50+
plugin. If an episode is already open when you import, a changed default playback
51+
rate applies to the next episode you open.
350 KB
Loading

src/settingsTransfer.test.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { describe, expect, it } from "vitest";
2+
import { DEFAULT_SETTINGS } from "./constants";
3+
import {
4+
EXCLUDED_KEYS,
5+
SETTINGS_EXPORT_TYPE,
6+
SETTINGS_EXPORT_VERSION,
7+
mergeImportedSettings,
8+
parseImport,
9+
serializeSettings,
10+
} from "./settingsTransfer";
11+
import type { IPodNotesSettings } from "./types/IPodNotesSettings";
12+
13+
function makeSettings(
14+
overrides: Partial<IPodNotesSettings> = {},
15+
): IPodNotesSettings {
16+
return structuredClone({ ...DEFAULT_SETTINGS, ...overrides });
17+
}
18+
19+
const NOW = "2026-06-14T00:00:00.000Z";
20+
21+
describe("serializeSettings", () => {
22+
it("wraps settings in a versioned envelope", () => {
23+
const envelope = serializeSettings(
24+
makeSettings(),
25+
{ includeSecret: false },
26+
"2.16.0",
27+
NOW,
28+
);
29+
30+
expect(envelope.type).toBe(SETTINGS_EXPORT_TYPE);
31+
expect(envelope.version).toBe(SETTINGS_EXPORT_VERSION);
32+
expect(envelope.pluginVersion).toBe("2.16.0");
33+
expect(envelope.exportedAt).toBe(NOW);
34+
});
35+
36+
it("excludes runtime/vault-specific state even when populated", () => {
37+
const settings = makeSettings({
38+
playedEpisodes: { ep: { title: "ep", podcastName: "p", time: 1, duration: 2, finished: false } },
39+
podNotes: { ep: { title: "ep", podcastName: "p" } as never },
40+
downloadedEpisodes: { p: [] },
41+
currentEpisode: { title: "ep" } as never,
42+
});
43+
44+
const { settings: exported } = serializeSettings(
45+
settings,
46+
{ includeSecret: false },
47+
"2.16.0",
48+
NOW,
49+
);
50+
51+
for (const key of EXCLUDED_KEYS) {
52+
expect(exported).not.toHaveProperty(key as string);
53+
}
54+
});
55+
56+
it("omits the API key unless includeSecret is set", () => {
57+
const settings = makeSettings({ openAIApiKey: "sk-secret" });
58+
59+
const without = serializeSettings(settings, { includeSecret: false }, "2.16.0", NOW);
60+
expect(without.settings).not.toHaveProperty("openAIApiKey");
61+
62+
const withKey = serializeSettings(settings, { includeSecret: true }, "2.16.0", NOW);
63+
expect(withKey.settings.openAIApiKey).toBe("sk-secret");
64+
});
65+
66+
it("includes preferences, templates, and library", () => {
67+
const settings = makeSettings({
68+
note: { path: "notes/{{title}}.md", template: "# {{title}}" },
69+
savedFeeds: {
70+
Show: { title: "Show", url: "https://example.com/feed", artworkUrl: "" },
71+
},
72+
});
73+
74+
const { settings: exported } = serializeSettings(
75+
settings,
76+
{ includeSecret: false },
77+
"2.16.0",
78+
NOW,
79+
);
80+
81+
expect(exported.note).toEqual(settings.note);
82+
expect(exported.savedFeeds).toEqual(settings.savedFeeds);
83+
expect(exported.timestamp).toEqual(settings.timestamp);
84+
});
85+
});
86+
87+
describe("parseImport", () => {
88+
it("rejects invalid JSON", () => {
89+
const result = parseImport("not json {");
90+
expect(result.ok).toBe(false);
91+
});
92+
93+
it("rejects a non-object payload", () => {
94+
expect(parseImport("[]").ok).toBe(false);
95+
expect(parseImport("42").ok).toBe(false);
96+
expect(parseImport("null").ok).toBe(false);
97+
});
98+
99+
it("rejects an envelope from a newer format version", () => {
100+
const result = parseImport(
101+
JSON.stringify({
102+
type: SETTINGS_EXPORT_TYPE,
103+
version: SETTINGS_EXPORT_VERSION + 1,
104+
settings: { defaultVolume: 0.5 },
105+
}),
106+
);
107+
expect(result.ok).toBe(false);
108+
if (!result.ok) expect(result.error).toContain("newer version");
109+
});
110+
111+
it("rejects a non-integer or invalid version", () => {
112+
for (const version of [0, -1, 1.5]) {
113+
const result = parseImport(
114+
JSON.stringify({
115+
type: SETTINGS_EXPORT_TYPE,
116+
version,
117+
settings: { defaultVolume: 0.5 },
118+
}),
119+
);
120+
expect(result.ok).toBe(false);
121+
}
122+
});
123+
124+
it("round-trips an exported envelope", () => {
125+
const settings = makeSettings({
126+
defaultPlaybackRate: 1.5,
127+
note: { path: "p", template: "t" },
128+
});
129+
const envelope = serializeSettings(settings, { includeSecret: false }, "2.16.0", NOW);
130+
131+
const result = parseImport(JSON.stringify(envelope));
132+
expect(result.ok).toBe(true);
133+
if (result.ok) {
134+
expect(result.meta.fromEnvelope).toBe(true);
135+
expect(result.settings.defaultPlaybackRate).toBe(1.5);
136+
expect(result.settings.note).toEqual({ path: "p", template: "t" });
137+
}
138+
});
139+
140+
it("accepts a raw settings object and drops excluded runtime keys", () => {
141+
const raw = makeSettings({
142+
defaultVolume: 0.3,
143+
playedEpisodes: { ep: { title: "ep", podcastName: "p", time: 1, duration: 2, finished: true } },
144+
currentEpisode: { title: "ep" } as never,
145+
});
146+
147+
const result = parseImport(JSON.stringify(raw));
148+
expect(result.ok).toBe(true);
149+
if (result.ok) {
150+
expect(result.meta.fromEnvelope).toBe(false);
151+
expect(result.settings.defaultVolume).toBe(0.3);
152+
for (const key of EXCLUDED_KEYS) {
153+
expect(result.settings).not.toHaveProperty(key as string);
154+
}
155+
}
156+
});
157+
158+
it("drops unknown keys", () => {
159+
const result = parseImport(
160+
JSON.stringify({ defaultVolume: 0.5, somethingElse: true }),
161+
);
162+
expect(result.ok).toBe(true);
163+
if (result.ok) expect(result.settings).not.toHaveProperty("somethingElse");
164+
});
165+
166+
it("fails when no recognizable settings remain", () => {
167+
const result = parseImport(JSON.stringify({ somethingElse: true }));
168+
expect(result.ok).toBe(false);
169+
});
170+
171+
it("drops a wrong-typed top-level value", () => {
172+
const result = parseImport(
173+
JSON.stringify({ defaultVolume: "evil", note: { path: "p", template: "t" } }),
174+
);
175+
expect(result.ok).toBe(true);
176+
if (result.ok) {
177+
expect(result.settings).not.toHaveProperty("defaultVolume");
178+
expect(result.settings.note).toEqual({ path: "p", template: "t" });
179+
}
180+
});
181+
182+
it("drops a wrong-typed nested field but keeps valid siblings", () => {
183+
const result = parseImport(
184+
JSON.stringify({ note: { path: 5, template: "keep" } }),
185+
);
186+
expect(result.ok).toBe(true);
187+
if (result.ok) {
188+
expect(result.settings.note).toEqual({ template: "keep" });
189+
}
190+
});
191+
192+
it("rejects a typed envelope without a numeric version", () => {
193+
const result = parseImport(
194+
JSON.stringify({
195+
type: SETTINGS_EXPORT_TYPE,
196+
settings: { defaultVolume: 0.5 },
197+
}),
198+
);
199+
expect(result.ok).toBe(false);
200+
});
201+
202+
it("does not pollute Object.prototype via __proto__", () => {
203+
const result = parseImport(
204+
'{"defaultVolume": 0.5, "__proto__": {"polluted": true}}',
205+
);
206+
expect(result.ok).toBe(true);
207+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
208+
});
209+
210+
it("does not pollute via a malicious envelope payload", () => {
211+
const result = parseImport(
212+
`{"type":"${SETTINGS_EXPORT_TYPE}","version":1,"settings":{"constructor":{"x":1},"defaultVolume":0.5}}`,
213+
);
214+
expect(result.ok).toBe(true);
215+
if (result.ok) expect(result.settings).not.toHaveProperty("constructor");
216+
expect(({} as Record<string, unknown>).x).toBeUndefined();
217+
});
218+
219+
it("reports whether the import includes the API key", () => {
220+
const withKey = parseImport(JSON.stringify({ openAIApiKey: "sk-x" }));
221+
expect(withKey.ok).toBe(true);
222+
if (withKey.ok) expect(withKey.meta.includesSecret).toBe(true);
223+
224+
const withoutKey = parseImport(JSON.stringify({ defaultVolume: 0.5 }));
225+
expect(withoutKey.ok).toBe(true);
226+
if (withoutKey.ok) expect(withoutKey.meta.includesSecret).toBe(false);
227+
});
228+
});
229+
230+
describe("mergeImportedSettings", () => {
231+
it("overrides preferences while preserving excluded runtime state", () => {
232+
const current = makeSettings({
233+
defaultVolume: 1,
234+
playedEpisodes: { ep: { title: "ep", podcastName: "p", time: 5, duration: 9, finished: false } },
235+
});
236+
237+
const merged = mergeImportedSettings(current, { defaultVolume: 0.25 });
238+
239+
expect(merged.defaultVolume).toBe(0.25);
240+
expect(merged.playedEpisodes).toEqual(current.playedEpisodes);
241+
});
242+
243+
it("backfills partial nested objects from defaults so no field is blanked", () => {
244+
const current = makeSettings();
245+
// A hand-edited file that only sets timestamp.offset.
246+
const merged = mergeImportedSettings(current, {
247+
timestamp: { offset: 7 } as never,
248+
});
249+
250+
expect(merged.timestamp.offset).toBe(7);
251+
expect(merged.timestamp.template).toBe(DEFAULT_SETTINGS.timestamp.template);
252+
});
253+
254+
it("replaces collection settings wholesale rather than merging them", () => {
255+
const current = makeSettings({
256+
savedFeeds: {
257+
B: { title: "B", url: "https://b.example/feed", artworkUrl: "" },
258+
},
259+
});
260+
261+
const merged = mergeImportedSettings(current, {
262+
savedFeeds: {
263+
A: { title: "A", url: "https://a.example/feed", artworkUrl: "" },
264+
},
265+
});
266+
267+
expect(Object.keys(merged.savedFeeds)).toEqual(["A"]);
268+
});
269+
270+
it("keeps the current nested value when the import omits the field", () => {
271+
const current = makeSettings({
272+
note: { path: "custom/{{title}}.md", template: "custom" },
273+
});
274+
275+
const merged = mergeImportedSettings(current, { defaultVolume: 0.5 });
276+
277+
expect(merged.note).toEqual({ path: "custom/{{title}}.md", template: "custom" });
278+
});
279+
});

0 commit comments

Comments
 (0)