Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
3f74a8a
refactor(transcription): let the promise settle once in waitForLifecycle
chhoumann Oct 15, 2026
71af6a9
refactor(transcription): make the retry and concurrency limits module…
chhoumann Oct 15, 2026
0b9c752
refactor(parser): make the parser's stateless helpers module functions
chhoumann Oct 15, 2026
4cc96d4
refactor(player): make the saved-progress helpers module functions
chhoumann Oct 15, 2026
80e7a58
refactor(player): use isPlaying for the playing-or-loading checks
chhoumann Oct 15, 2026
fadb9e4
refactor(player): compare episodes with isSameEpisode
chhoumann Oct 15, 2026
264c71d
refactor(download): drop the media type hint that is always audio
chhoumann Oct 15, 2026
b094c2d
refactor(download): drop the unread byteLength of a whole-file download
chhoumann Oct 15, 2026
ec0e383
refactor(download): drop the download notice's never-passed empty flag
chhoumann Oct 15, 2026
731aee8
refactor(transcription): make the queue's episode key a module function
chhoumann Oct 15, 2026
361942d
refactor(download): match media signatures with find and every
chhoumann Oct 15, 2026
b45c588
refactor(itunes): build the iTunes request URL in fetchShows
chhoumann Oct 15, 2026
e605864
refactor(player): trust the decoded default volume
chhoumann Oct 15, 2026
391f1ff
refactor(transcription): keep the lifetime signal in a field
chhoumann Oct 15, 2026
fc39a8a
refactor(ui): drop TimerNotice's redundant timer bookkeeping
chhoumann Oct 15, 2026
1af79d3
refactor(ui): share the popout-following resize observer in layout.ts
chhoumann Oct 15, 2026
d0b44f8
refactor(ui): attach the video stage instead of using it as an action
chhoumann Oct 15, 2026
191e055
refactor(ui): compile PlaylistsPage in runes mode
chhoumann Oct 15, 2026
8f68e4a
refactor(ui): end QueueList's drag in an effect teardown
chhoumann Oct 15, 2026
58e6d29
refactor(ui): set the view's dynamic classes without clsx
chhoumann Oct 15, 2026
9989ec6
refactor(uri): drop the unreachable segment start guard
chhoumann Oct 15, 2026
6b33e69
refactor(persistence): decode every collection map through one helper
chhoumann Oct 15, 2026
a183f46
refactor(persistence): stop decoding fallback episodes that are alway…
chhoumann Oct 15, 2026
93cdc28
refactor(templates): prefix tag lines through one helper
chhoumann Oct 15, 2026
bd8a828
refactor(templates): define the episode date and duration tags once
chhoumann Oct 15, 2026
b11c22e
refactor(templates): build the four timestamp tags from one factory
chhoumann Oct 15, 2026
4a01823
refactor(notes): share one createFileIfNotExists for episode and feed…
chhoumann Oct 15, 2026
8ac9fa4
refactor(network): read option and response snapshots through one helper
chhoumann Oct 15, 2026
ddd5347
refactor(persistence): warn about normalized numbers through one helper
chhoumann Oct 15, 2026
a69fb6a
refactor(network): drop two IPv6 parse checks that later checks cover
chhoumann Oct 15, 2026
7c6815e
refactor(dates): stop regex-escaping letter-only date tokens
chhoumann Oct 15, 2026
75a2493
refactor(uri): read the plugin and the URL candidates once per link
chhoumann Oct 15, 2026
f388293
refactor(plugin): inline the two one-caller view mount methods
chhoumann Oct 15, 2026
3f21d03
refactor(menu): decide the play item titles once in the file menu han…
chhoumann Oct 15, 2026
dda6d52
refactor(played): build a played list entry in one object
chhoumann Oct 15, 2026
4a142a8
refactor(store): bind persisted stores without forwarding wrappers
chhoumann Oct 15, 2026
0539802
refactor(utility): match a URL extension with the regex literal itself
chhoumann Oct 15, 2026
d27df0c
refactor(network): percent-encode parentheses with two plain replaces
chhoumann Oct 15, 2026
dc53227
refactor(utility): filter title matches without scoring them
chhoumann Oct 15, 2026
677015c
refactor(utility): stop clamping segment times formatSeconds clamps
chhoumann Oct 15, 2026
4ddbefd
refactor(utility): drop falsy checks that instanceof and Array.isArra…
chhoumann Oct 15, 2026
dcfa201
refactor(paths): cap folder names without a second length check
chhoumann Oct 15, 2026
d729b29
refactor(network): test the zero-width and mark code points as one range
chhoumann Oct 15, 2026
886502f
refactor(network): list the special-use domains once
chhoumann Oct 15, 2026
62104e1
refactor(renames): map the moved records with the shared mapRecord
chhoumann Oct 15, 2026
abf9c61
refactor(network): reuse isPlainObject and ownRecord for options and …
chhoumann Oct 15, 2026
f96d59d
refactor(download): map content types to extensions as plain tuples
chhoumann Oct 15, 2026
13ddd83
refactor(store): start the built-in playlist stores from their settings
chhoumann Oct 15, 2026
a3fd88e
refactor(templates): stop regex-escaping an alphanumeric extension
chhoumann Oct 15, 2026
e4f6b59
refactor(persistence): only delete invalid media types and virtual flags
chhoumann Oct 15, 2026
3532402
refactor(network): check HTTP status codes through one guard
chhoumann Oct 15, 2026
8c6cc86
Merge remote-tracking branch 'origin/master' into refactor/size-headroom
chhoumann Oct 15, 2026
cb70923
refactor(ui): make the video stage a real attachment
chhoumann Oct 15, 2026
3652136
refactor(templates): drop joinLines' restating doc comment
chhoumann Oct 15, 2026
ded9c10
test: pin numeric-string segment times and inherited episode metadata
chhoumann Oct 15, 2026
df7aa93
Revert "refactor(utility): stop clamping segment times formatSeconds …
chhoumann Oct 15, 2026
36c0b79
Revert "refactor(persistence): only delete invalid media types and vi…
chhoumann Oct 15, 2026
1c72ce1
Merge branch 'master' into refactor/size-headroom
chhoumann Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/API/API.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ describe("API.getPodcastSegmentFormatted", () => {
);
});

test("formats the numeric strings that JavaScript workflows pass as times", () => {
const { api, player } = setup();
player.restore(feedEpisode);
const seconds = (value: string) => value as unknown as number;

expect(
api.getPodcastSegmentFormatted("HH:mm:ss", seconds("115"), seconds("125"), true),
).toBe("00:01:55-00:02:05");
expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, seconds("125"))).toBe(
"00:01:55-00:02:05",
);
});

test("seeking through the public API ends a segment link's stop", () => {
const { api, player, media } = setup();
player.play(feedEpisode, { time: 115, endTime: 125 });
Expand Down
178 changes: 68 additions & 110 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,28 @@ function addEpisodeFileNameTags(
): void {
addTag("title", legalizedNameTag(episode.title, sanitizeFileName));
addTag("podcast", legalizedNameTag(episode.podcastName, sanitizeFileName));
addTag("date", (format?: string) =>
episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "",
addEpisodeDateTags(addTag, episode);
}

function addEpisodeDateTags(addTag: AddTagFn, episode: Episode): void {
addTag("date", (format = "YYYY-MM-DD") =>
episode.episodeDate ? formatDate(episode.episodeDate, format) : "",
);
addTag("currentdate", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD"));
// The current date the note is created on, distinct from {{date}} (the episode
// publish date). Supports the same Moment.js format arg. See issue #75.
addTag("currentdate", (format = "YYYY-MM-DD") => formatDate(new Date(), format));
// Episode number from <itunes:episode>, else best-effort from the title. See #34.
addTag("episodenumber", (pad?: string) =>
formatEpisodeNumber(resolveEpisodeNumber(episode), pad),
);
}

// Episode duration from <itunes:duration>. See issue #88.
function durationTag(episode: Episode): TagValue {
return (format?: string) =>
episode.duration !== undefined ? formatDuration(episode.duration, format) : "";
}

function formatChapterTitle(title: string): string {
return title.replace(/\s+/g, " ").trim();
}
Expand Down Expand Up @@ -270,6 +283,10 @@ function feedHtmlToMarkdown(html: string, source: object): string {
);
}

function joinLines(lines: string[], prependToLines?: string): string {
return (prependToLines ? lines.map((line) => `${prependToLines}${line}`) : lines).join("\n");
}

function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines?: string): string {
const lines = normalizeChapters(chapters ?? []).map((chapter) => {
const title = formatChapterTitle(chapter.title);
Expand All @@ -278,11 +295,7 @@ function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines?
return `- ${formatDuration(chapter.startTime)}${escapedTitle}`;
});

if (!prependToLines) {
return lines.join("\n");
}

return lines.map((line) => `${prependToLines}${line}`).join("\n");
return joinLines(lines, prependToLines);
}

/**
Expand Down Expand Up @@ -317,48 +330,23 @@ export function NoteTemplateEngine(
const episodeUrl = resolveEpisodeUrl(episode);

addTag("title", escapeMarkdownBodyText(episode.title));
addTag("description", (prependToLines?: string) => {
// reduce multiple new lines
const sanitizeDescription = feedHtmlToMarkdown(episode.description, liveEpisode).replace(
/\n{3,}/g,
"\n\n",
);
if (prependToLines) {
return sanitizeDescription
.split("\n")
.map((str) => `${prependToLines}${str}`)
.join("\n");
}

return sanitizeDescription;
});
addTag("content", (prependToLines?: string) => {
if (prependToLines) {
return feedHtmlToMarkdown(episode.content, liveEpisode)
.split("\n")
.map((str) => `${prependToLines}${str}`)
.join("\n");
}

return feedHtmlToMarkdown(episode.content, liveEpisode);
});
addTag("description", (prependToLines?: string) =>
joinLines(
// reduce multiple new lines
feedHtmlToMarkdown(episode.description, liveEpisode)
.replace(/\n{3,}/g, "\n\n")
.split("\n"),
prependToLines,
),
);
addTag("content", (prependToLines?: string) =>
joinLines(feedHtmlToMarkdown(episode.content, liveEpisode).split("\n"), prependToLines),
);
addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title));
addTag("stream", sanitizePortableResourceUrl(episode.streamUrl));
addTag("url", episodeUrl);
addTag("date", (format?: string) =>
episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "",
);
// The current date the note is created on, distinct from {{date}} (the episode
// publish date). Supports the same Moment.js format arg. See issue #75.
addTag("currentdate", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD"));
// Episode number from <itunes:episode>, else best-effort from the title. See #34.
addTag("episodenumber", (pad?: string) =>
formatEpisodeNumber(resolveEpisodeNumber(episode), pad),
);
// Episode duration from <itunes:duration>. See issue #88.
addTag("duration", (format?: string) =>
episode.duration !== undefined ? formatDuration(episode.duration, format) : "",
);
addEpisodeDateTags(addTag, episode);
addTag("duration", durationTag(episode));
// Podcasting 2.0 chapters, fetched before note creation when the template
// asks for them. Empty when the feed has no chapters URL or fetching fails.
addTag("chapters", (prependToLines?: string) =>
Expand Down Expand Up @@ -401,36 +389,23 @@ export function TimestampTemplateEngine(
const { api, settings } = get(plugin);
const timestampOffset = settings.timestamp.offset ?? 0;

addTag("time", (format?: string) =>
api.getPodcastTimeFormatted(format ?? "HH:mm:ss", false, timestampOffset),
);
addTag("linktime", (format?: string) =>
api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true, timestampOffset),
);
addTag("segment", (format?: string) => {
if (!options.segment) {
return api.getPodcastTimeFormatted(format ?? "HH:mm:ss", false, timestampOffset);
}

return api.getPodcastSegmentFormatted(
format ?? "HH:mm:ss",
options.segment.startTime,
options.segment.endTime,
false,
);
});
addTag("linksegment", (format?: string) => {
if (!options.segment) {
return api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true, timestampOffset);
}

return api.getPodcastSegmentFormatted(
format ?? "HH:mm:ss",
options.segment.startTime,
options.segment.endTime,
true,
);
});
// Without a segment, {{segment}} renders the current time as {{time}} does.
const timeTag =
(linkify: boolean, segment?: PodcastSegmentTimes): TagValue =>
(format = "HH:mm:ss") =>
segment
? api.getPodcastSegmentFormatted(
format,
segment.startTime,
segment.endTime,
linkify,
)
: api.getPodcastTimeFormatted(format, linkify, timestampOffset);

addTag("time", timeTag(false));
addTag("linktime", timeTag(true));
addTag("segment", timeTag(false, options.segment));
addTag("linksegment", timeTag(true, options.segment));

return replacer(template);
}
Expand All @@ -453,12 +428,10 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) {
// FIRST '.ext' followed by '?'/'#'/end, which need not be the trailing one, so
// a positional `.replace(ext, "")` could corrupt a folder name that happens to
// contain the same string earlier in the path (#DL-04).
// getUrlExtension returns letters and digits only, so the extension needs no escaping.
const templateExtension = getUrlExtension(template);
const templateWithoutExtension = templateExtension
? template.replace(
new RegExp(`\\.${templateExtension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"),
"",
)
? template.replace(new RegExp(`\\.${templateExtension}$`, "i"), "")
: template;

const [replacer, addTag] = useTemplateEngine();
Expand All @@ -480,20 +453,11 @@ export function TranscriptTemplateEngine(
] as const);

addEpisodeFileNameTags(addTag, episode);
addTag("duration", (format?: string) =>
episode.duration !== undefined ? formatDuration(episode.duration, format) : "",
);
addTag("duration", durationTag(episode));
addTag("transcript", transcription);
addTag("description", (prependToLines?: string) => {
if (prependToLines) {
return feedHtmlToMarkdown(episode.description, liveEpisode)
.split("\n")
.map((str) => `${prependToLines}${str}`)
.join("\n");
}

return feedHtmlToMarkdown(episode.description, liveEpisode);
});
addTag("description", (prependToLines?: string) =>
joinLines(feedHtmlToMarkdown(episode.description, liveEpisode).split("\n"), prependToLines),
);
addTag("url", resolveEpisodeUrl(episode));
addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));

Expand Down Expand Up @@ -522,21 +486,15 @@ export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed)
addTag("artwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("author", escapeMarkdownBodyText(feed.author ?? ""));
addTag("description", (prependToLines?: string) => {
const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "", context).replace(
/\n{3,}/g,
"\n\n",
);
if (prependToLines) {
return sanitizeDescription
.split("\n")
.map((str) => `${prependToLines}${str}`)
.join("\n");
}

return sanitizeDescription;
});
addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD"));
addTag("description", (prependToLines?: string) =>
joinLines(
feedHtmlToMarkdown(feed.description ?? "", context)
.replace(/\n{3,}/g, "\n\n")
.split("\n"),
prependToLines,
),
);
addTag("date", (format = "YYYY-MM-DD") => formatDate(new Date(), format));

return replacer(template);
}
Expand All @@ -547,7 +505,7 @@ export function FeedFilePathTemplateEngine(template: string, feed: PodcastFeed)
const nameTag = legalizedNameTag(withoutFeedAddresses(feed).title);
addTag("title", nameTag);
addTag("podcast", nameTag);
addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD"));
addTag("date", (format = "YYYY-MM-DD") => formatDate(new Date(), format));

return replacer(template);
}
Expand Down
22 changes: 8 additions & 14 deletions src/URIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export default async function podNotesURIHandler(
return;
}
// One guard ahead of both the segment and plain paths: a negative time is
// never valid (the segment path's own requestedTime < 0 check is now redundant).
// never valid.
if (requestedTime < 0) {
new Notice("Timestamp must be zero or greater");
return;
Expand All @@ -125,16 +125,16 @@ export default async function podNotesURIHandler(
if (requestedEndTime === null) return;

const nameCandidates = candidateValues(episodeName);
const player = get(plugin).player;
const { app, feedUrls, feeds, player } = get(plugin);
const playOptions = hasExplicitTime ? { time: requestedTime, endTime: requestedEndTime } : {};
const loaded = get(player.state).episode;
const { app, feedUrls } = get(plugin);
// A link carrying a saved private feed's secret URL opens as the feed's placeholder
// link, so the secret stays out of the episode.
const url =
Object.values(get(savedFeeds)).find((saved) => isSavedFeed(saved, linkUrl, feedUrls))
?.url ?? linkUrl;
const localFile = candidateValues(url)
const urlCandidates = candidateValues(url);
const localFile = urlCandidates
.map((path) => app.vault.getAbstractFileByPath(path))
.find((file) => file instanceof TFile);
// A link to the loaded episode names its feed, or its file for a local file, so a
Expand All @@ -155,7 +155,7 @@ export default async function podNotesURIHandler(
!!loaded &&
!!loadedLinkTarget &&
(!isLocalFile(loaded) || localFile?.path === loadedLinkTarget) &&
candidateValues(url).includes(loadedLinkTarget) &&
urlCandidates.includes(loadedLinkTarget) &&
(!guid || loaded.guid === guid) &&
(nameCandidates.includes(loaded.title) ||
nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1));
Expand Down Expand Up @@ -188,8 +188,8 @@ export default async function podNotesURIHandler(
getUnambiguousMediaTypeFromPath(localFile.path) ??
(named ? getEpisodeMediaType(named) : "audio"),
);
} else if (!isPlaceholderLink && !candidateValues(url).some((u) => /^https?:\/\//i.test(u))) {
const { paths, name, files } = filesNamedLike(app.vault.getFiles(), candidateValues(url));
} else if (!isPlaceholderLink && !urlCandidates.some((u) => /^https?:\/\//i.test(u))) {
const { paths, name, files } = filesNamedLike(app.vault.getFiles(), urlCandidates);
if (files.length !== 1) {
new Notice(
files.length === 0
Expand Down Expand Up @@ -219,7 +219,6 @@ export default async function podNotesURIHandler(
// SecretStorage. An attacker-crafted placeholder link can therefore only
// make the user's own device fetch the user's own subscribed feed - and
// the resolved URL still passes the network fetchability gate.
const feeds = get(plugin).feeds;
const placeholderKey = parsePrivateFeedPlaceholder(url);
if (placeholderKey !== null) {
// Only a feed whose URL is on this device is fetched, and a newer link or play
Expand All @@ -229,7 +228,7 @@ export default async function podNotesURIHandler(
const saved = get(savedFeeds);
const fetches =
Object.prototype.hasOwnProperty.call(saved, placeholderKey) &&
resolveFeedUrl(saved[placeholderKey], get(plugin).feedUrls) !== null;
resolveFeedUrl(saved[placeholderKey], feedUrls) !== null;
const request = fetches ? ++latestRequest : latestRequest;
if (fetches) play = player.deferPlay();
// Fetch even a fresh feed so the link finds an episode published since.
Expand Down Expand Up @@ -302,11 +301,6 @@ function parseSegmentEndTime(
return null;
}

if (requestedTime < 0) {
new Notice("Segment start time must be zero or greater");
return null;
}

const parsed = parseSeconds(rawEndTime);
if (!Number.isFinite(parsed)) {
new Notice("Segment end time must be a valid number");
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const DEFAULT_EPISODE_LIST_LIMIT = 10;
*/
export const MAX_EPISODE_LIST_LIMIT = 75;

type PlaylistSettings = Pick<
export type PlaylistSettings = Pick<
Playlist,
"icon" | "name" | "shouldEpisodeRemoveAfterPlay" | "shouldRepeat"
>;
Expand Down
Loading
Loading