Skip to content
Merged
2 changes: 1 addition & 1 deletion obsidian-e2e.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default {
"---\n" +
"# {{title}}\n\n" +
"![]({{artwork}})\n\n" +
"[Resume in PodNotes]({{episodelink}})\n\n" +
"{{episodelink:Resume in PodNotes}}\n\n" +
"{{url}}\n\n" +
"{{description}}\n",
},
Expand Down
27 changes: 22 additions & 5 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ export function templateHasTag(template: string, tag: Lowercase<string>): boolea
);
}

// A title names the placeholder only when it cannot spell the feed's address:
// it holds no character reference or percent escape, and redaction leaves it
// alone as text, where trailing punctuation cannot pass an address off as a
// distinct whole-field URL.
function feedRedactionContext(feed: PodcastFeed) {
const title = ` ${feed.title} `;
const named =
!/&#|&\w+;|%[\da-f]{2}/i.test(title) &&
withoutPrivateFeedAddresses([title, { feedUrl: feed.url }] as const)[0] === title;
return { feedUrl: feed.url, podcastName: named ? feed.title : "" };
}

function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed {
return withoutPrivateFeedAddresses([feed, { feedUrl: feed.url }] as const)[0];
}
Expand Down Expand Up @@ -373,7 +385,10 @@ export function NoteTemplateEngine(
// never been played). The resume point is resolved at click time, not baked
// in here, so a single templated link always jumps to where you left off.
// Empty when the episode has no feed/file URL to address it by. See issue #35.
addTag("episodelink", buildEpisodeResumeLink(liveEpisode));
addTag("episodelink", (label?: string) => {
const link = buildEpisodeResumeLink(liveEpisode);
return label === undefined || !link ? link : `[${escapeMarkdownText(label)}](${link})`;
});

return replacer(template);
}
Expand Down Expand Up @@ -487,7 +502,8 @@ export function TranscriptTemplateEngine(

export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed) {
const [replacer, addTag] = useTemplateEngine();
const feed = withoutFeedAddresses(liveFeed);
const context = feedRedactionContext(liveFeed);
const [feed] = withoutPrivateFeedAddresses([liveFeed, context] as const);

const safeTitle = replaceIllegalFileNameCharactersInString(feed.title);

Expand All @@ -507,9 +523,10 @@ export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed)
addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("author", escapeMarkdownBodyText(feed.author ?? ""));
addTag("description", (prependToLines?: string) => {
const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "", {
feedUrl: liveFeed.url,
}).replace(/\n{3,}/g, "\n\n");
const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "", context).replace(
/\n{3,}/g,
"\n\n",
);
if (prependToLines) {
return sanitizeDescription
.split("\n")
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
"---\n" +
"# {{title}}\n\n" +
"![]({{artwork}})\n\n" +
"[Resume in PodNotes]({{episodelink}})\n\n" +
"{{episodelink:Resume in PodNotes}}\n\n" +
"{{url}}\n\n" +
"{{description}}\n",
},
Expand Down
88 changes: 86 additions & 2 deletions src/createPodcastNote.privacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,90 @@ test("an unsubscribed private episode writes no feed address and keeps its live
expect(episode.feedUrl).toBe(address);
});

test("the default note omits an empty resume label and keeps public resume links", () => {
noteVault(false);
const publicEpisode = { ...episode, feedUrl: "https://public.example.com/rss" };
expect(NoteTemplateEngine(DEFAULT_SETTINGS.note.template, publicEpisode)).toContain(
`[Resume in PodNotes](${buildEpisodeResumeLink(publicEpisode)})`,
);
expect(NoteTemplateEngine(DEFAULT_SETTINGS.note.template, episode)).not.toContain(
"Resume in PodNotes",
);
});

test("a saved template keeps its resume line, and a resume label is plain text", () => {
noteVault(false);
const saved = "[Resume in PodNotes]({{episodelink}})";
const publicEpisode = { ...episode, feedUrl: "https://public.example.com/rss" };
const link =
"obsidian://podnotes?episodeName=Private%20episode&url=https%3A%2F%2Fpublic.example.com%2Frss&guid=episode-1";
expect(NoteTemplateEngine(saved, episode)).toBe("[Resume in PodNotes]()");
expect(NoteTemplateEngine(saved, publicEpisode)).toBe(`[Resume in PodNotes](${link})`);
expect(NoteTemplateEngine("{{episodelink:Resume [here]}}", publicEpisode)).toBe(
`[Resume \\[here\\]](${link})`,
);
});

test("an unsubscribed feed note names its safe placeholder and leaves a public feed alone", () => {
noteVault(false);
const feed = {
title: "Paid",
url: address,
artworkUrl: "",
description: `Your feed: ${address}.`,
};
const publicUrl = "https://public.example.com/rss";
expect(
FeedNoteTemplateEngine("{{description}}", {
...feed,
url: publicUrl,
description: `Your feed: ${publicUrl}.`,
}),
).toBe(`Your feed: ${publicUrl}.`);
expect(FeedNoteTemplateEngine("{{description}}", feed)).toBe(
"Your feed: podnotes-private-feed:Paid.",
);
const doubleEscaped = `Your feed: ${address.replace(":", "&amp;#58;")}.`;
expect(FeedNoteTemplateEngine("{{description}}", { ...feed, description: doubleEscaped })).toBe(
"Your feed: podnotes-private-feed:Paid.",
);
});

test.each([
address,
`${address}.`,
address.replace(":", "&#58;"),
address.replace(":", "&amp;#58;"),
address.replace(":", "%253A"),
encodeURIComponent(address),
])(
"an unsubscribed feed placeholder never reflects its private address from its name: %s",
(title) => {
noteVault(false);
const rendered = FeedNoteTemplateEngine("{{description}}", {
title,
url: address,
artworkUrl: "",
description: `Your feed: ${address}.`,
});
expect(rendered).toBe("Your feed: podnotes-private-feed:.");
},
);

test.each(["Paid", "Q&A", "99% Invisible"])(
"an unsubscribed feed placeholder takes the plain title %s as its name",
(title) => {
noteVault(false);
const rendered = FeedNoteTemplateEngine("{{description}}", {
title,
url: address,
artworkUrl: "",
description: `Your feed: ${address}.`,
});
expect(rendered).toBe(`Your feed: podnotes-private-feed:${encodeURIComponent(title)}.`);
},
);

test.each([
address,
"https://user:API_SENTINEL@private.example.com/rss",
Expand All @@ -116,7 +200,7 @@ test.each([
await createPodcastNoteFileIfNotExists(quoted);
expect(written).toHaveLength(1);
expect(written[0].path).toBe(path);
expect(written[0].content).toContain("[Resume in PodNotes]()");
expect(written[0].content).not.toContain("Resume in PodNotes");
expect(written[0].content).not.toMatch(/^(guid|feedUrl):/m);
expect(written[0].content).not.toContain("NOTE_SENTINEL");
},
Expand Down Expand Up @@ -182,7 +266,7 @@ test("a feed note for an unsubscribed private feed writes no feed address", () =
description: `Your feed: ${address}.`,
};
expect(FeedNoteTemplateEngine("{{feedurl}}|{{description}}", feed)).toBe(
"|Your feed: podnotes-private-feed:.",
"|Your feed: podnotes-private-feed:Paid.",
);
expect(FeedFilePathTemplateEngine("{{title}}", { ...feed, title: `Paid ${address}` })).toBe(
"Paid podnotes-private-feed",
Expand Down
85 changes: 85 additions & 0 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Notice, type SecretStorage } from "obsidian";
import * as obsidian from "obsidian";
import { DEFAULT_SETTINGS } from "./constants";
import PodNotes from "./main";
import * as privateFeedUrls from "./utility/privateFeedUrl";
import * as persistenceMerge from "./persistence/merge";
import { encodePodNotesData } from "./persistence/podNotesData";
import * as podNotesData from "./persistence/podNotesData";
import { PlayerController } from "./player/PlayerController";
Expand Down Expand Up @@ -1007,6 +1009,89 @@ describe("PodNotes persistence integration", () => {
expect(saveData.mock.lastCall?.[0].queue.episodes).toHaveLength(2);
});

it("does not rescan an equal repair just because restoration copied it", async () => {
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData, { dataFileExists: true });
const rawUrl = "https://private.example/feed.xml?auth=EQUAL_REPAIR_SENTINEL";
const initial = JSON.parse(JSON.stringify(encodePodNotesData(plugin.settings)));
initial.savedFeeds = { Private: { title: "Private", url: rawUrl, artworkUrl: "" } };
await plugin.saveData(initial);
await plugin.loadSettings();
const synced = JSON.parse(JSON.stringify(encodePodNotesData(plugin.settings)));
synced.savedFeeds.Private.url = rawUrl;
await plugin.saveData(synced);
saveData.mockClear();
const repairs = vi.spyOn(persistenceMerge, "restoreDropped");
const redactions = vi.spyOn(privateFeedUrls, "redactPrivateFeedAddresses");

plugin.settings.defaultVolume = 0.4;
await plugin.saveSettingsStrict();

expect(
repairs.mock.results.some(
(result, index) =>
result.type === "return" &&
result.value !== repairs.mock.calls[index][1] &&
persistenceMerge.jsonValuesEqual(result.value, repairs.mock.calls[index][1]),
),
).toBe(true);
expect(redactions).toHaveBeenCalledTimes(5);
expect(saveData).toHaveBeenCalledTimes(1);
expect(saveData.mock.lastCall?.[0]).toMatchObject({
defaultVolume: 0.4,
savedFeeds: { Private: { url: "podnotes-private-feed:Private" } },
});
expect(JSON.stringify(saveData.mock.calls)).not.toContain("EQUAL_REPAIR_SENTINEL");
});

describe("when a synced private feed arrives with a queue that had to be repaired", () => {
const rawUrl = "https://private.example/feed.xml?auth=RESTORED_SENTINEL";
const episode = {
title: "Ep",
podcastName: "Pod",
streamUrl: "https://example.com/ep.mp3",
url: "",
content: "",
};

async function saveAfterSync(description: string) {
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData, { dataFileExists: true });
await plugin.loadSettings();
plugin.settings.queue.episodes = [{ ...episode, description }];
attachDataFileWriter(plugin);
const synced = JSON.parse(JSON.stringify(encodePodNotesData(plugin.settings)));
synced.queue = "not a playlist";
synced.savedFeeds = { Private: { title: "Private", url: rawUrl, artworkUrl: "" } };
await plugin.saveData(synced);
saveData.mockClear();

plugin.settings.defaultVolume = 0.4;
await plugin.saveSettingsStrict();
return saveData;
}

it("redacts the address in the entries it keeps from this device", async () => {
const saveData = await saveAfterSync(`Your feed: ${rawUrl}`);

const written = saveData.mock.lastCall?.[0];
expect(written.queue.episodes).toEqual([
{ ...episode, description: "Your feed: podnotes-private-feed:Private" },
]);
expect(written.savedFeeds.Private.url).toBe("podnotes-private-feed:Private");
expect(JSON.stringify(saveData.mock.calls)).not.toContain("RESTORED_SENTINEL");
});

it("keeps the entries unchanged when they quote no private address", async () => {
const saveData = await saveAfterSync("Public notes");

const written = saveData.mock.lastCall?.[0];
expect(written.queue.episodes).toEqual([{ ...episode, description: "Public notes" }]);
expect(written.savedFeeds.Private.url).toBe("podnotes-private-feed:Private");
expect(written.defaultVolume).toBe(0.4);
});
});

it("drops a saved feed cache preference at the next save, even one an older device syncs back", async () => {
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData, { dataFileExists: true });
Expand Down
15 changes: 11 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,14 +582,21 @@ export default class PodNotes extends Plugin implements IPodNotes {
// An entry the codec dropped while repairing a value (an invalid episode, a
// collection reset) is not a deletion on the other device. Keep it.
const raw = data as Record<string, unknown>;
let restored = false;
for (const [key, value] of Object.entries(base ?? {})) {
if (!jsonValuesEqual(raw[key], doc[key])) {
doc[key] = restoreDropped(value, doc[key], raw[key], [key]);
}
if (jsonValuesEqual(raw[key], doc[key])) continue;
const kept = restoreDropped(value, doc[key], raw[key], [key]);
if (jsonValuesEqual(kept, doc[key])) continue;
doc[key] = kept;
restored = true;
}
// toDocument redacted the rest. A restored entry can quote a private address,
// or name a private feed whose address other fields quote.
return {
kind: "file",
doc: redactPrivateFeedAddresses(doc, addresses) as PersistedPodNotesDataV2,
doc: (restored
? redactPrivateFeedAddresses(doc, addresses)
: doc) as PersistedPodNotesDataV2,
};
},
write: (doc) =>
Expand Down
25 changes: 25 additions & 0 deletions src/services/privateFeeds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,31 @@ function episode(title: string, feedUrl: string) {
}

describe("protectPrivateFeeds", () => {
it("redacts entity-escaped descriptions before persistence without changing public bytes", () => {
const { feedUrls } = repository();
const address = "https://private.example.com/rss?auth=PERSIST_ENTITY_SENTINEL";
const escaped = address.replace(":", "&#58;").replaceAll("/", "&#x2F;");
const publicText = "Public https&#58;&#x2F;&#x2F;private.example.com/public?a=1&amp;b=2";
const nested = `https://redirect.example/?u=${encodeURIComponent(address)}&amp;x=1`;
const input = settings({
savedFeeds: {
Paid: feed({
title: "Paid",
url: address,
description: `${escaped}&period; ${nested} ${publicText}`,
}),
Public: feed({ description: publicText }),
},
});
const { settings: safe } = protectPrivateFeeds(input, feedUrls);
expect(safe.savedFeeds.Public).toEqual(input.savedFeeds.Public);
expect(safe.savedFeeds.Paid.description).toBe(
`podnotes-private-feed:Paid&period; https://redirect.example/?u=podnotes-private-feed%3APaid&amp;x=1 ${publicText}`,
);
expect(JSON.stringify(encodePodNotesData(safe))).not.toContain("PERSIST_ENTITY_SENTINEL");
expect(protectPrivateFeeds(safe, feedUrls).settings).toBe(safe);
});

it("moves only credential-bearing feeds", () => {
const { feedUrls, values } = repository();
const { settings: safe, failed } = protectPrivateFeeds(
Expand Down
14 changes: 4 additions & 10 deletions src/services/publishedTranscript/parse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isPlainObject } from "src/persistence/codecUtils";
import { COMMON_NAMED_ENTITIES } from "src/utility/htmlEntities";

export type TranscriptFormat = "vtt" | "srt" | "json" | "html" | "text";

Expand Down Expand Up @@ -165,16 +166,9 @@ function toSeconds(value: unknown): number | undefined {
return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
}

const NAMED_ENTITIES = new Map([
["amp", "&"],
["lt", "<"],
["gt", ">"],
["quot", '"'],
["apos", "'"],
["nbsp", "\u00a0"],
["lrm", "\u200e"],
["rlm", "\u200f"],
]);
const NAMED_ENTITIES = new Map(
Object.entries({ ...COMMON_NAMED_ENTITIES, lrm: "\u200e", rlm: "\u200f" }),
);

function decodeEntities(text: string): string {
return text.replace(/&(?:#(\d+)|#x([\da-f]+)|([a-z]+));/gi, (match, decimal, hex, name) => {
Expand Down
Loading
Loading