diff --git a/.editorconfig b/.editorconfig index 84b8a66d..c1cbc601 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,14 @@ -# top-most EditorConfig file -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -indent_style = tab -indent_size = 4 -tab_width = 4 +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = 4 +tab_width = 4 + +[*.{yaml,yml}] +indent_style = space +indent_size = 2 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e6050cee..10d6f923 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: chhoumann -custom: https://www.buymeacoffee.com/chhoumann \ No newline at end of file +custom: https://www.buymeacoffee.com/chhoumann diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0114c2d..f6b45495 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: dryRun: - description: 'Dry run (no actual release)' + description: "Dry run (no actual release)" required: false default: false type: boolean diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 9d10cfe9..beef9ff8 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,5 +1,14 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "useTabs": true, - "ignorePatterns": [] + "ignorePatterns": [], + "overrides": [ + { + "files": ["**/*.yaml", "**/*.yml"], + "options": { + "tabWidth": 2, + "useTabs": false + } + } + ] } diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e04c0264..2bb26267 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -2,19 +2,19 @@ site_name: PodNotes repo_url: https://github.com/chhoumann/podnotes edit_uri: edit/master/docs/docs nav: - - Home: index.md - - Commands: commands.md - - Podcasts: podcasts.md - - 'Local files': local_files.md - - "Import & Export": import_export.md - - Transcripts: transcripts.md - - 'Notes': - - Timestamps: timestamps.md - - Templates: templates.md - - Advanced: - - API: api.md - - Usage with QuickAdd: QuickAdd.md + - Home: index.md + - Commands: commands.md + - Podcasts: podcasts.md + - "Local files": local_files.md + - "Import & Export": import_export.md + - Transcripts: transcripts.md + - "Notes": + - Timestamps: timestamps.md + - Templates: templates.md + - Advanced: + - API: api.md + - Usage with QuickAdd: QuickAdd.md theme: - name: material - palette: - primary: blue + name: material + palette: + primary: blue diff --git a/package.json b/package.json index 813121c7..f5639483 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", "lint": "oxlint --deny-warnings src tests/e2e scripts", - "format:check": "oxfmt --check package.json manifest.json tsconfig.json .oxlintrc.json .oxfmtrc.json vite.config.ts vitest.config.ts vitest.e2e.config.ts tests/e2e scripts", + "format:check": "npm run format -- --check", "version": "node version-bump.mjs && git add manifest.json versions.json", "semantic-release": "semantic-release", "test": "npm run check:a11y && vitest", @@ -27,7 +27,7 @@ "check:a11y": "svelte-check --fail-on-warnings", "docs:build": "mkdocs build -f docs/mkdocs.yml -d site", "docs:deploy": "npm run docs:build && wrangler pages deploy docs/site --project-name podnotes --branch master", - "format": "oxfmt package.json manifest.json tsconfig.json .oxlintrc.json .oxfmtrc.json vite.config.ts vitest.config.ts vitest.e2e.config.ts tests/e2e scripts" + "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.json .oxlintrc.json .oxfmtrc.json vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts version-bump.mjs wrangler.jsonc orca.yaml docs/mkdocs.yml" }, "dependencies": { "fuse.js": "^7.4.2", diff --git a/src/API/API.test.ts b/src/API/API.test.ts index efcddf80..17c34ee3 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -54,21 +54,14 @@ describe("API.getPodcastSegmentFormatted", () => { currentEpisode.set(feedEpisode); const api = new API(); - expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125)).toBe( - "00:01:55-00:02:05", - ); + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125)).toBe("00:01:55-00:02:05"); }); test("links feed episodes with start and end times", () => { currentEpisode.set(feedEpisode); const api = new API(); - const rendered = api.getPodcastSegmentFormatted( - "HH:mm:ss", - 115, - 125, - true, - ); + const rendered = api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125, true); expect(rendered).toContain("[00:01:55-00:02:05]"); expect(rendered).toContain("time=115"); @@ -106,9 +99,9 @@ describe("API.getPodcastSegmentFormatted", () => { expect(api.getPodcastSegmentFormatted("HH:mm:ss", 126, 125, true)).toBe( "00:02:06-00:02:05", ); - expect( - api.getPodcastSegmentFormatted("HH:mm:ss", 125, Number.NaN, true), - ).toBe("00:02:05-00:00:00"); + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 125, Number.NaN, true)).toBe( + "00:02:05-00:00:00", + ); }); test("seeking through the public API clears an active playback segment", () => { @@ -263,12 +256,9 @@ describe("API volume (AP-07)", () => { describe("API transcript access", () => { function setTranscriptVault(files: Record) { - const createTFile = (path: string): TFile => - Object.assign(new TFile(), { path }); + const createTFile = (path: string): TFile => Object.assign(new TFile(), { path }); const getAbstractFileByPath = vi.fn((path: string) => - Object.prototype.hasOwnProperty.call(files, path) - ? createTFile(path) - : null, + Object.prototype.hasOwnProperty.call(files, path) ? createTFile(path) : null, ); const read = vi.fn(async (file: TFile) => files[file.path] ?? ""); diff --git a/src/API/API.ts b/src/API/API.ts index 0c382c1c..69e8b408 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -16,10 +16,7 @@ import { import { get } from "svelte/store"; import encodePodnotesURI from "src/utility/encodePodnotesURI"; import { isLocalFile } from "src/utility/isLocalFile"; -import { - formatPodcastSegment, - normalizePodcastSegmentTimes, -} from "src/utility/podcastSegment"; +import { formatPodcastSegment, normalizePodcastSegmentTimes } from "src/utility/podcastSegment"; import { adjustPlaybackRate, normalizePlaybackRate, @@ -39,12 +36,8 @@ const normalizeSkipLength = (length: number): number => // two-way binds the store onto the media element. Fall back to the current value // (itself clamped) so a bad write is a no-op rather than corrupting playback. const clampVolume = (value: number, fallback = 1): number => { - const safeFallback = Number.isFinite(fallback) - ? Math.min(1, Math.max(0, fallback)) - : 1; - return Number.isFinite(value) - ? Math.min(1, Math.max(0, value)) - : safeFallback; + const safeFallback = Number.isFinite(fallback) ? Math.min(1, Math.max(0, fallback)) : 1; + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : safeFallback; }; export class API implements IAPI { @@ -96,11 +89,7 @@ export class API implements IAPI { * @param offsetSeconds Optional offset to subtract from the current playback time. * @returns */ - getPodcastTimeFormatted( - format: string, - linkify = false, - offsetSeconds = 0, - ): string { + getPodcastTimeFormatted(format: string, linkify = false, offsetSeconds = 0): string { if (!this.podcast) { throw new Error("No podcast loaded"); } @@ -118,11 +107,7 @@ export class API implements IAPI { return time; } - const url = encodePodnotesURI( - this.podcast.title, - feedUrl, - adjustedTime, - ); + const url = encodePodnotesURI(this.podcast.title, feedUrl, adjustedTime); return `[${time}](${url.href})`; } @@ -139,11 +124,7 @@ export class API implements IAPI { const segmentTimes = normalizePodcastSegmentTimes(startTime, endTime); const segment = segmentTimes - ? formatPodcastSegment( - segmentTimes.startTime, - segmentTimes.endTime, - format, - ) + ? formatPodcastSegment(segmentTimes.startTime, segmentTimes.endTime, format) : formatPodcastSegment(startTime, endTime, format); if (!linkify || !segmentTimes) return segment; @@ -174,8 +155,7 @@ export class API implements IAPI { episode, pluginInstance.settings.transcript.path, ); - const transcriptFile = - pluginInstance.app.vault.getAbstractFileByPath(transcriptPath); + const transcriptFile = pluginInstance.app.vault.getAbstractFileByPath(transcriptPath); if (!(transcriptFile instanceof TFile)) { return null; @@ -204,18 +184,14 @@ export class API implements IAPI { } skipBackward(): void { - const skipBackLen = normalizeSkipLength( - get(plugin).settings.skipBackwardLength, - ); + const skipBackLen = normalizeSkipLength(get(plugin).settings.skipBackwardLength); // Never seek before the start. A cleared settings field (NaN/null) falls // back to the default rather than corrupting currentTime (PB-02). this.currentTime = Math.max(0, this.currentTime - skipBackLen); } skipForward(): void { - const skipForwardLen = normalizeSkipLength( - get(plugin).settings.skipForwardLength, - ); + const skipForwardLen = normalizeSkipLength(get(plugin).settings.skipForwardLength); const target = this.currentTime + skipForwardLen; const dur = this.length; // Clamp just short of the end so an over-skip lands at the end instead of @@ -224,21 +200,15 @@ export class API implements IAPI { // not rewind, so keep at least the current time (PB-02 / Codex review #213). // With an unknown/zero duration (metadata not loaded) leave it unclamped. this.currentTime = - dur > 0 - ? Math.max(this.currentTime, Math.min(target, dur - 0.25)) - : target; + dur > 0 ? Math.max(this.currentTime, Math.min(target, dur - 0.25)) : target; } increasePlaybackRate(): void { - playbackRateStore.update((rate) => - adjustPlaybackRate(rate, PLAYBACK_RATE_STEP), - ); + playbackRateStore.update((rate) => adjustPlaybackRate(rate, PLAYBACK_RATE_STEP)); } decreasePlaybackRate(): void { - playbackRateStore.update((rate) => - adjustPlaybackRate(rate, -PLAYBACK_RATE_STEP), - ); + playbackRateStore.update((rate) => adjustPlaybackRate(rate, -PLAYBACK_RATE_STEP)); } resetPlaybackRate(): void { diff --git a/src/API/IAPI.ts b/src/API/IAPI.ts index 7c17fc50..1885d31f 100644 --- a/src/API/IAPI.ts +++ b/src/API/IAPI.ts @@ -1,4 +1,4 @@ -import type { Episode } from 'src/types/Episode'; +import type { Episode } from "src/types/Episode"; export interface IAPI { readonly podcast: Episode; @@ -9,11 +9,7 @@ export interface IAPI { playbackRate: number; volume: number; - getPodcastTimeFormatted( - format: string, - linkify?: boolean, - offsetSeconds?: number, - ): string; + getPodcastTimeFormatted(format: string, linkify?: boolean, offsetSeconds?: number): string; getPodcastSegmentFormatted( format: string, @@ -23,7 +19,7 @@ export interface IAPI { ): string; getTranscript(episode?: Episode): Promise; - + start(): void; stop(): void; togglePlayback(): void; diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index 3b1def82..a540d7c2 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -44,9 +44,7 @@ describe("replaceIllegalFileNameCharactersInString (via DownloadPathTemplateEngi it("strips square brackets so a feed title cannot inject a wikilink", () => { // '[' and ']' are wikilink-significant; a feed must not be able to // smuggle a [[wikilink]] through a file-name/link tag (other-wikilink-injection). - expect(sanitizeTitle("Real]] [[Victims Private Note")).toBe( - "Real Victims Private Note", - ); + expect(sanitizeTitle("Real]] [[Victims Private Note")).toBe("Real Victims Private Note"); expect(sanitizeTitle("a[b]c")).toBe("abc"); }); @@ -65,9 +63,7 @@ describe("replaceIllegalFileNameCharactersInString (via DownloadPathTemplateEngi }); it("does not strip hyphens or ordinary letters", () => { - expect(sanitizeTitle("Part 1 - The Beginning")).toBe( - "Part 1 - The Beginning", - ); + expect(sanitizeTitle("Part 1 - The Beginning")).toBe("Part 1 - The Beginning"); }); }); @@ -85,24 +81,24 @@ const demoEpisode: Episode = { describe("DownloadPathTemplateEngine extension stripping (#DL-04)", () => { it("strips only a trailing template extension", () => { - expect( - DownloadPathTemplateEngine("Podcasts/{{title}}.mp3", demoEpisode), - ).toBe("Podcasts/Episode 1"); + expect(DownloadPathTemplateEngine("Podcasts/{{title}}.mp3", demoEpisode)).toBe( + "Podcasts/Episode 1", + ); }); it("does not corrupt a folder that contains the extension string earlier in the path", () => { // getUrlExtension returns the trailing 'mp3', but the old positional // `.replace('mp3', '')` would strip the FIRST 'mp3' (in the folder name), // mangling 'mp3folder' -> 'folder' and leaving the real '.mp3' behind. - expect( - DownloadPathTemplateEngine("mp3folder/{{title}}.mp3", demoEpisode), - ).toBe("mp3folder/Episode 1"); + expect(DownloadPathTemplateEngine("mp3folder/{{title}}.mp3", demoEpisode)).toBe( + "mp3folder/Episode 1", + ); }); it("leaves a template without a trailing extension untouched", () => { - expect( - DownloadPathTemplateEngine("Podcasts/{{title}}", demoEpisode), - ).toBe("Podcasts/Episode 1"); + expect(DownloadPathTemplateEngine("Podcasts/{{title}}", demoEpisode)).toBe( + "Podcasts/Episode 1", + ); }); }); @@ -120,15 +116,13 @@ describe("TimestampTemplateEngine segment tags", () => { format: string, linkify: boolean, offsetSeconds: number, - ) => - `time:${format}:${linkify ? "link" : "plain"}:${offsetSeconds}`, + ) => `time:${format}:${linkify ? "link" : "plain"}:${offsetSeconds}`, getPodcastSegmentFormatted: ( format: string, startTime: number, endTime: number, linkify: boolean, - ) => - `segment:${format}:${startTime}-${endTime}:${linkify ? "link" : "plain"}`, + ) => `segment:${format}:${startTime}-${endTime}:${linkify ? "link" : "plain"}`, }, } as never); }); @@ -182,15 +176,11 @@ describe("NoteTemplateEngine feed-scoped tags (#163)", () => { it("adds episode aliases and {{feedurl}}", () => { plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never); - expect(NoteTemplateEngine("{{episodeurl}}", demoEpisode)).toBe( - "https://example.com/ep1", - ); + expect(NoteTemplateEngine("{{episodeurl}}", demoEpisode)).toBe("https://example.com/ep1"); expect(NoteTemplateEngine("{{episodeartwork}}", demoEpisode)).toBe( "https://example.com/ep1.png", ); - expect(NoteTemplateEngine("{{feedurl}}", demoEpisode)).toBe( - "https://example.com/feed.xml", - ); + expect(NoteTemplateEngine("{{feedurl}}", demoEpisode)).toBe("https://example.com/feed.xml"); }); it("resolves {{feedartwork}} from the saved feed, else the episode art", () => { @@ -259,13 +249,11 @@ describe("NoteTemplateEngine renders URL tags verbatim (#160 review)", () => { const localFile = { ...demoEpisode, podcastName: "local file", - filePath: "Audio/Talk \"A\".mp3", + filePath: 'Audio/Talk "A".mp3', url: '[[Talk "A".mp3]]', } as Episode; expect(NoteTemplateEngine("{{url}}", localFile)).toBe('[[Talk "A".mp3]]'); - expect(NoteTemplateEngine("{{episodeurl}}", localFile)).toBe( - '[[Talk "A".mp3]]', - ); + expect(NoteTemplateEngine("{{episodeurl}}", localFile)).toBe('[[Talk "A".mp3]]'); }); it("renders a normal episode URL and artwork verbatim", () => { @@ -306,18 +294,12 @@ describe("default note template renders valid frontmatter (#160)", () => { podcastName: "local file", filePath: 'Audio/Talk "A".mp3', } as Episode; - const rendered = NoteTemplateEngine( - DEFAULT_SETTINGS.note.template, - episode, - ); + const rendered = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, episode); const frontmatter = frontmatterOf(rendered); - const line = (key: string) => - frontmatter.split("\n").find((l) => l.startsWith(`${key}:`)); + const line = (key: string) => frontmatter.split("\n").find((l) => l.startsWith(`${key}:`)); // The podcast link is quoted so its leading [[ isn't read as a flow sequence. - expect(line("podcast")).toBe( - 'podcast: "[[PodNotes/Podcasts/local file|local file]]"', - ); + expect(line("podcast")).toBe('podcast: "[[PodNotes/Podcasts/local file|local file]]"'); // The url is NOT in the frontmatter (it could carry a quote for local files). expect(line("url")).toBeUndefined(); // Every frontmatter line has balanced double-quotes. @@ -334,13 +316,8 @@ describe("default note template renders valid frontmatter (#160)", () => { }); it("renders an ISO date when present and an empty (null) date otherwise", () => { - const withDate = NoteTemplateEngine( - DEFAULT_SETTINGS.note.template, - demoEpisode, - ); - expect( - withDate.split("\n").find((l) => l.startsWith("date:")), - ).toBe("date: 2024-01-01"); + const withDate = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, demoEpisode); + expect(withDate.split("\n").find((l) => l.startsWith("date:"))).toBe("date: 2024-01-01"); const noDate = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, { ...demoEpisode, @@ -364,15 +341,9 @@ describe("FeedNoteTemplateEngine (#163)", () => { it("maps {{url}}/{{artwork}} to the feed and exposes feed metadata", () => { expect(FeedNoteTemplateEngine("{{url}}", feed)).toBe("https://example.com"); - expect(FeedNoteTemplateEngine("{{feedurl}}", feed)).toBe( - "https://example.com/feed.xml", - ); - expect(FeedNoteTemplateEngine("{{artwork}}", feed)).toBe( - "https://example.com/art.png", - ); - expect(FeedNoteTemplateEngine("{{feedartwork}}", feed)).toBe( - "https://example.com/art.png", - ); + expect(FeedNoteTemplateEngine("{{feedurl}}", feed)).toBe("https://example.com/feed.xml"); + expect(FeedNoteTemplateEngine("{{artwork}}", feed)).toBe("https://example.com/art.png"); + expect(FeedNoteTemplateEngine("{{feedartwork}}", feed)).toBe("https://example.com/art.png"); expect(FeedNoteTemplateEngine("{{author}}", feed)).toBe("Jane Doe"); // htmlToMarkdown is a passthrough in the test mock. expect(FeedNoteTemplateEngine("{{description}}", feed)).toBe("<p>Great show</p>"); @@ -413,15 +384,13 @@ describe("FeedFilePathTemplateEngine (#163)", () => { }; it("sanitizes the feed title for {{podcast}} and {{title}}", () => { - expect( - FeedFilePathTemplateEngine("PodNotes/Podcasts/{{podcast}}.md", feed), - ).toBe("PodNotes/Podcasts/My Show A Podcast.md"); + expect(FeedFilePathTemplateEngine("PodNotes/Podcasts/{{podcast}}.md", feed)).toBe( + "PodNotes/Podcasts/My Show A Podcast.md", + ); }); it("supports a whitespace-replacement argument", () => { - expect(FeedFilePathTemplateEngine("{{podcast:-}}", feed)).toBe( - "My-Show-A-Podcast", - ); + expect(FeedFilePathTemplateEngine("{{podcast:-}}", feed)).toBe("My-Show-A-Podcast"); }); }); @@ -439,9 +408,7 @@ describe("getFeedNoteWikilink (#163)", () => { plugin.set({ settings: { feedNote: { path: "{{podcast}}.md" } }, } as never); - expect(getFeedNoteWikilink("My Show: A Podcast")).toBe( - "[[My Show A Podcast]]", - ); + expect(getFeedNoteWikilink("My Show: A Podcast")).toBe("[[My Show A Podcast]]"); }); it("falls back to a plain sanitized link when no path is configured", () => { @@ -454,10 +421,7 @@ describe("getFeedNoteWikilink (#163)", () => { settings: { feedNote: { path: "PodNotes/Podcasts/{{podcast}}.md" } }, } as never); const link = getFeedNoteWikilink("Z".repeat(400)); - const linkPath = link - .replace(/^\[\[/, "") - .replace(/\]\]$/, "") - .split("|")[0]; + const linkPath = link.replace(/^\[\[/, "").replace(/\]\]$/, "").split("|")[0]; const basename = linkPath.split("/").pop() ?? ""; // Without the cap the link would embed all 400 chars and never resolve. expect(basename.length).toBeLessThanOrEqual(255); @@ -483,24 +447,20 @@ describe("{{currentDate}} tag (#75)", () => { }); it("supports a Moment.js format and is distinct from the episode {{date}}", () => { - expect( - NoteTemplateEngine("{{currentDate:YYYY}} vs {{date:YYYY}}", demoEpisode), - ).toBe("2026 vs 2024"); + expect(NoteTemplateEngine("{{currentDate:YYYY}} vs {{date:YYYY}}", demoEpisode)).toBe( + "2026 vs 2024", + ); }); it("supports a format containing commas (not truncated by the engine)", () => { - expect( - NoteTemplateEngine("{{currentDate:MMMM D, YYYY}}", demoEpisode), - ).toBe("June 15, 2026"); + expect(NoteTemplateEngine("{{currentDate:MMMM D, YYYY}}", demoEpisode)).toBe( + "June 15, 2026", + ); }); it("is available in file-path and download-path templates", () => { - expect(FilePathTemplateEngine("{{currentDate}}", demoEpisode)).toBe( - "2026-06-15", - ); - expect(DownloadPathTemplateEngine("{{currentDate}}", demoEpisode)).toBe( - "2026-06-15", - ); + expect(FilePathTemplateEngine("{{currentDate}}", demoEpisode)).toBe("2026-06-15"); + expect(DownloadPathTemplateEngine("{{currentDate}}", demoEpisode)).toBe("2026-06-15"); }); }); @@ -535,12 +495,12 @@ describe("{{episodeNumber}} tag (#34)", () => { }); it("is available (and file-safe) in file-path and download-path templates", () => { - expect( - FilePathTemplateEngine("{{episodeNumber:000}} {{title}}", numbered), - ).toBe("042 Episode 1"); - expect( - DownloadPathTemplateEngine("{{episodeNumber:000}} {{title}}", numbered), - ).toBe("042 Episode 1"); + expect(FilePathTemplateEngine("{{episodeNumber:000}} {{title}}", numbered)).toBe( + "042 Episode 1", + ); + expect(DownloadPathTemplateEngine("{{episodeNumber:000}} {{title}}", numbered)).toBe( + "042 Episode 1", + ); }); }); @@ -562,9 +522,7 @@ describe("{{duration}} tag (#88)", () => { }); it("supports a Moment.js clock format", () => { - expect(NoteTemplateEngine("{{duration:HH:mm:ss}}", withDuration)).toBe( - "01:02:03", - ); + expect(NoteTemplateEngine("{{duration:HH:mm:ss}}", withDuration)).toBe("01:02:03"); }); it("renders a zero duration as 0:00 (not empty)", () => { @@ -578,12 +536,8 @@ describe("{{duration}} tag (#88)", () => { it("is not registered in file-path/download-path templates (left unreplaced)", () => { // Intentionally absent there — the clock format's colons are path-illegal. - expect(FilePathTemplateEngine("{{duration}}", withDuration)).toBe( - "{{duration}}", - ); - expect(DownloadPathTemplateEngine("{{duration}}", withDuration)).toBe( - "{{duration}}", - ); + expect(FilePathTemplateEngine("{{duration}}", withDuration)).toBe("{{duration}}"); + expect(DownloadPathTemplateEngine("{{duration}}", withDuration)).toBe("{{duration}}"); }); }); @@ -666,9 +620,9 @@ describe("TranscriptTemplateEngine new tags (#75/#34/#88)", () => { it("leaves number/duration empty when absent", () => { const blank: Episode = { ...demoEpisode, title: "A Show With No Number" }; - expect( - TranscriptTemplateEngine("[{{episodeNumber}}][{{duration}}]", blank, "t"), - ).toBe("[][]"); + expect(TranscriptTemplateEngine("[{{episodeNumber}}][{{duration}}]", blank, "t")).toBe( + "[][]", + ); }); }); @@ -721,8 +675,7 @@ describe("feed content injection is neutralized (deepsec other-markdown-injectio it("collapses newlines and neutralizes Markdown in a raw {{title}}", () => { const malicious: Episode = { ...demoEpisode, - title: - "Real Title\n\n# Injected Heading\n\n![pixel](http://attacker.example/t.png)", + title: "Real Title\n\n# Injected Heading\n\n![pixel](http://attacker.example/t.png)", }; const rendered = NoteTemplateEngine("# {{title}}", malicious); @@ -806,11 +759,9 @@ describe("feed content injection is neutralized (deepsec other-markdown-injectio // A title with ordinary punctuation (dots, colons, quotes, parens) is kept. const punctuated: Episode = { ...demoEpisode, - title: 'Ep. 5: A.I. & You (Part 1)', + title: "Ep. 5: A.I. & You (Part 1)", }; - expect(NoteTemplateEngine("# {{title}}", punctuated)).toBe( - "# Ep. 5: A.I. & You (Part 1)", - ); + expect(NoteTemplateEngine("# {{title}}", punctuated)).toBe("# Ep. 5: A.I. & You (Part 1)"); }); }); @@ -845,9 +796,7 @@ describe("feed-controlled wikilink injection is neutralized (deepsec other-wikil "[[PodNotes/Podcasts/Real Victims Private Note|Real Victims Private Note]]", ); - expect(NoteTemplateEngine("{{podcast}}", malicious)).toBe( - "Real Victims Private Note", - ); + expect(NoteTemplateEngine("{{podcast}}", malicious)).toBe("Real Victims Private Note"); expect( NoteTemplateEngine("{{safetitle}}", { ...demoEpisode, diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 25526f66..49a5c9c8 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -39,10 +39,7 @@ export interface NoteTemplateContext { chapters?: Chapter[]; } -export function templateHasTag( - template: string, - tag: Lowercase<string>, -): boolean { +export function templateHasTag(template: string, tag: Lowercase<string>): boolean { return Array.from(template.matchAll(TEMPLATE_TAG_REGEX)).some( ([, tagId]) => tagId.toLowerCase() === tag, ); @@ -51,10 +48,7 @@ export function templateHasTag( function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { const tags: Tags = {}; - function addTag( - tag: Lowercase<string>, - value: TagValue, - ): void { + function addTag(tag: Lowercase<string>, value: TagValue): void { tags[tag] = value; } @@ -75,9 +69,7 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { new Notice( `Tag ${tagId} is invalid.${ - similarTag.length > 0 - ? ` Did you mean ${similarTag[0].item}?` - : "" + similarTag.length > 0 ? ` Did you mean ${similarTag[0].item}?` : "" }`, ); return match; @@ -126,9 +118,7 @@ function resolveEpisodeNumber(episode: Episode): number | undefined { function legalizedNameTag(rawValue: string): TagValue { return (whitespaceReplacement?: string) => { const legal = replaceIllegalFileNameCharactersInString(rawValue); - return whitespaceReplacement - ? legal.replace(/\s+/g, whitespaceReplacement) - : legal; + return whitespaceReplacement ? legal.replace(/\s+/g, whitespaceReplacement) : legal; }; } @@ -143,13 +133,9 @@ function addEpisodeFileNameTags(addTag: AddTagFn, episode: Episode): void { addTag("title", legalizedNameTag(episode.title)); addTag("podcast", legalizedNameTag(episode.podcastName)); addTag("date", (format?: string) => - episode.episodeDate - ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") - : "", - ); - addTag("currentdate", (format?: string) => - formatDate(new Date(), format ?? "YYYY-MM-DD"), + episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", ); + addTag("currentdate", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); addTag("episodenumber", (pad?: string) => formatEpisodeNumber(resolveEpisodeNumber(episode), pad), ); @@ -210,10 +196,7 @@ function sanitizeInlineText(text: string): string { * vault), so this has no false positives on genuine show notes. */ function neutralizeExecutableCodeBlocks(markdown: string): string { - return markdown.replace( - /(`{3,}|~{3,})[ \t]*(?:dataviewjs|dataview)\b/gi, - "$1text", - ); + return markdown.replace(/(`{3,}|~{3,})[ \t]*(?:dataviewjs|dataview)\b/gi, "$1text"); } /** @@ -236,10 +219,7 @@ function feedHtmlToMarkdown(html: string): string { return neutralizeExecutableCodeBlocks(htmlToMarkdown(html)); } -function formatTemplateChapters( - chapters: Chapter[] | undefined, - prependToLines?: string, -): string { +function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines?: string): string { const lines = normalizeChapters(chapters ?? []).map((chapter) => { const title = formatChapterTitle(chapter.title); const escapedTitle = title ? ` ${escapeMarkdownText(title)}` : ""; @@ -309,34 +289,25 @@ export function NoteTemplateEngine( addTag("stream", sanitizeUrlForTemplate(episode.streamUrl)); addTag("url", episodeUrl); addTag("date", (format?: string) => - episode.episodeDate - ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") - : "", + 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"), - ); + 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) - : "", + episode.duration !== undefined ? formatDuration(episode.duration, format) : "", ); // 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) => formatTemplateChapters(context.chapters, prependToLines), ); - addTag( - "podcast", - replaceIllegalFileNameCharactersInString(episode.podcastName), - ); + addTag("podcast", replaceIllegalFileNameCharactersInString(episode.podcastName)); addTag("artwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); // Feed-scoped tags so an episode note can reference its parent podcast feed @@ -345,13 +316,10 @@ export function NoteTemplateEngine( addTag("episodeurl", episodeUrl); addTag("episodeartwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? "")); - const parentFeed = - get(plugin)?.settings?.savedFeeds?.[episode.podcastName]; + const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName]; addTag( "feedartwork", - sanitizeUrlForTemplate( - parentFeed?.artworkUrl ?? episode.artworkUrl ?? "", - ), + sanitizeUrlForTemplate(parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""), ); // A ready-made wikilink to the parent feed's note, pointing at the same file // createFeedNote writes (derived from the feed-note path setting). @@ -382,11 +350,7 @@ export function TimestampTemplateEngine( ); addTag("segment", (format?: string) => { if (!options.segment) { - return api.getPodcastTimeFormatted( - format ?? "HH:mm:ss", - false, - timestampOffset, - ); + return api.getPodcastTimeFormatted(format ?? "HH:mm:ss", false, timestampOffset); } return api.getPodcastSegmentFormatted( @@ -398,11 +362,7 @@ export function TimestampTemplateEngine( }); addTag("linksegment", (format?: string) => { if (!options.segment) { - return api.getPodcastTimeFormatted( - format ?? "HH:mm:ss", - true, - timestampOffset, - ); + return api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true, timestampOffset); } return api.getPodcastSegmentFormatted( @@ -433,10 +393,7 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) { const templateExtension = getUrlExtension(template); const templateWithoutExtension = templateExtension ? template.replace( - new RegExp( - `\\.${templateExtension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, - "i", - ), + new RegExp(`\\.${templateExtension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"), "", ) : template; @@ -457,9 +414,7 @@ export function TranscriptTemplateEngine( addEpisodeFileNameTags(addTag, episode); addTag("duration", (format?: string) => - episode.duration !== undefined - ? formatDuration(episode.duration, format) - : "", + episode.duration !== undefined ? formatDuration(episode.duration, format) : "", ); addTag("transcript", transcription); addTag("description", (prependToLines?: string) => { @@ -512,9 +467,7 @@ export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) { return sanitizeDescription; }); - addTag("date", (format?: string) => - formatDate(new Date(), format ?? "YYYY-MM-DD"), - ); + addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); return replacer(template); } @@ -525,9 +478,7 @@ export function FeedFilePathTemplateEngine(template: string, feed: PodcastFeed) const nameTag = legalizedNameTag(feed.title); addTag("title", nameTag); addTag("podcast", nameTag); - addTag("date", (format?: string) => - formatDate(new Date(), format ?? "YYYY-MM-DD"), - ); + addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); return replacer(template); } @@ -559,9 +510,7 @@ export function getFeedNoteWikilink(feedTitle: string): string { const linkPath = capped.replace(/\.md$/i, "").trim(); const basename = linkPath.split("/").pop()?.trim() || fallback; - return linkPath.includes("/") - ? `[[${linkPath}|${basename}]]` - : `[[${basename}]]`; + return linkPath.includes("/") ? `[[${linkPath}|${basename}]]` : `[[${basename}]]`; } /** diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 15acd895..668790e1 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -1,12 +1,5 @@ import { get } from "svelte/store"; -import { - afterEach, - beforeEach, - describe, - expect, - test, - vi, -} from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import podNotesURIHandler from "./URIHandler"; import { diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 0c94704b..438b06f3 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -48,9 +48,7 @@ function findEpisodeByCandidates( ): Episode | undefined { for (const name of nameCandidates) { const target = name.trim().toLowerCase(); - const episode = episodes.find( - (ep) => ep.title.trim().toLowerCase() === target, - ); + const episode = episodes.find((ep) => ep.title.trim().toLowerCase() === target); if (episode) return episode; } @@ -68,8 +66,7 @@ function resolveResumeTime(episode: Episode): number { const played = playedEpisodes.get(episode); if (!played) return 0; - const isFinished = - played.finished || (played.duration > 0 && played.time >= played.duration); + const isFinished = played.finished || (played.duration > 0 && played.time >= played.duration); return isFinished ? 0 : played.time; } @@ -184,9 +181,7 @@ export default async function podNotesURIHandler( // must not be able to fetch an arbitrary internal host. Refuse anything that // isn't a public http(s) URL before handing it to FeedParser (blind SSRF). if (!isFetchableUrl(url)) { - new Notice( - "Refusing to load a feed from a private, local, or non-http(s) URL", - ); + new Notice("Refusing to load a feed from a private, local, or non-http(s) URL"); return; } diff --git a/src/commands.ts b/src/commands.ts index 997b3106..5f4b1040 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,10 +4,7 @@ import { queue, savedFeeds } from "src/store"; import { QueueReorderModal } from "src/ui/QueueReorderModal"; import { TimestampTemplateEngine } from "src/TemplateEngine"; import { prepareTimestampForInsertion } from "src/utility/prepareTimestampInsertion"; -import { - createRecentPodcastSegment, - getSegmentCaptureTemplate, -} from "src/utility/podcastSegment"; +import { createRecentPodcastSegment, getSegmentCaptureTemplate } from "src/utility/podcastSegment"; import createPodcastNote from "src/createPodcastNote"; import createFeedNote from "src/createFeedNote"; import { FeedSuggestModal, orderFeedsByCurrent } from "src/ui/FeedSuggestModal"; @@ -23,8 +20,7 @@ import type PodNotes from "src/main"; * live state off the passed plugin (api/settings/app) at invocation time. */ export function registerCommands(plugin: PodNotes): void { - const canCaptureTimestamp = () => - !!plugin.api.podcast && !!plugin.settings.timestamp.template; + const canCaptureTimestamp = () => !!plugin.api.podcast && !!plugin.settings.timestamp.template; const insertCapture = (editor: Editor, capture: string) => { // Insert with replaceSelection (not getCursor + replaceRange + // setCursor): it drops the text at the live cursor and lets the @@ -143,10 +139,9 @@ export function registerCommands(plugin: PodNotes): void { // Settle the promise so a failed download surfaces in its own Notice // without leaving an unhandled rejection (DL-01). The notice itself is // the user-facing error; the log aids diagnosis. - void downloadEpisodeWithNotice( - episode, - plugin.settings.download.path, - ).catch((error) => console.error("PodNotes: download failed", error)); + void downloadEpisodeWithNotice(episode, plugin.settings.download.path).catch((error) => + console.error("PodNotes: download failed", error), + ); }, }); @@ -249,10 +244,7 @@ export function registerCommands(plugin: PodNotes): void { // Pre-select the playing episode's feed when there is one, so the // picker opens on the most likely choice without requiring playback. - const orderedFeeds = orderFeedsByCurrent( - feeds, - plugin.api.podcast?.podcastName, - ); + const orderedFeeds = orderFeedsByCurrent(feeds, plugin.api.podcast?.podcastName); new FeedSuggestModal(plugin.app, orderedFeeds, (feed) => { void createFeedNote(feed); @@ -334,8 +326,7 @@ export function registerCommands(plugin: PodNotes): void { // the service's context-aware "set your API key" Notice instead of the // command silently vanishing with no explanation (TR-02). const canTranscribe = - !!plugin.api.podcast && - getEpisodeMediaType(plugin.api.podcast) === "audio"; + !!plugin.api.podcast && getEpisodeMediaType(plugin.api.podcast) === "audio"; if (checking) { return canTranscribe; diff --git a/src/constants.ts b/src/constants.ts index 02888997..d11f9793 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -158,8 +158,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { diarizationApiKey: "", transcript: { path: "transcripts/{{podcast}}/{{title}}.md", - template: - "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}", + template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}", // Diarization is off by default so existing behaviour (plain Whisper) is // unchanged; enabling it routes audio to the chosen provider (#168). diarization: { diff --git a/src/createFeedNote.test.ts b/src/createFeedNote.test.ts index a1b0a257..b7f459cd 100644 --- a/src/createFeedNote.test.ts +++ b/src/createFeedNote.test.ts @@ -44,8 +44,7 @@ function setupVault(options: { const app = { vault: { - getAbstractFileByPath: (path: string) => - files.has(path) ? makeTFile(path) : null, + getAbstractFileByPath: (path: string) => (files.has(path) ? makeTFile(path) : null), create: vi.fn(create), createFolder: vi.fn(async (path: string) => { files.add(path); diff --git a/src/createFeedNote.ts b/src/createFeedNote.ts index 6cf32737..a62c012c 100644 --- a/src/createFeedNote.ts +++ b/src/createFeedNote.ts @@ -1,8 +1,5 @@ import { Notice, TFile } from "obsidian"; -import { - FeedFilePathTemplateEngine, - FeedNoteTemplateEngine, -} from "./TemplateEngine"; +import { FeedFilePathTemplateEngine, FeedNoteTemplateEngine } from "./TemplateEngine"; import type { PodcastFeed } from "./types/PodcastFeed"; import { get } from "svelte/store"; import { plugin } from "./store"; @@ -19,10 +16,7 @@ import FeedParser from "./parser/feedParser"; function getFeedNotePath(feed: PodcastFeed): string { const pluginInstance = get(plugin); - const filePath = FeedFilePathTemplateEngine( - pluginInstance.settings.feedNote.path, - feed, - ); + const filePath = FeedFilePathTemplateEngine(pluginInstance.settings.feedNote.path, feed); // Cap the path so a very long feed title can't trip ENAMETOOLONG (#22). Both // getFeedNote (existence/open) and createFeedNote derive the path here, so they @@ -57,9 +51,7 @@ export default async function createFeedNote(feed: PodcastFeed): Promise<void> { const { path, template } = pluginInstance.settings.feedNote; if (!path || !template) { - new Notice( - "Please set a podcast feed note path and template in the settings.", - ); + new Notice("Please set a podcast feed note path and template in the settings."); return; } diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 7d0f5690..8f21061d 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -74,9 +74,7 @@ describe("createPodcastNote chapters template support (#47)", () => { await createPodcastNote(episode); - expect(mockFetchChapters).toHaveBeenCalledWith( - "https://example.com/chapters.json", - ); + expect(mockFetchChapters).toHaveBeenCalledWith("https://example.com/chapters.json"); expect(createdFiles[0]).toMatchObject({ path: "PodNotes/Chaptered Episode.md", data: "# Chaptered Episode\n\n- 0:00 Intro\n- 1:05 Deep Dive", diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index ff26c5d1..aa8bf7f4 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -1,9 +1,5 @@ import { Notice, TFile } from "obsidian"; -import { - FilePathTemplateEngine, - NoteTemplateEngine, - templateHasTag, -} from "./TemplateEngine"; +import { FilePathTemplateEngine, NoteTemplateEngine, templateHasTag } from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import type { Chapter } from "./types/Chapter"; import { get } from "svelte/store"; @@ -22,17 +18,12 @@ import { fetchChapters } from "./utility/fetchChapters"; export function getPodcastNotePath(episode: Episode): string { const pluginInstance = get(plugin); - const filePath = FilePathTemplateEngine( - pluginInstance.settings.note.path, - episode, - ); + const filePath = FilePathTemplateEngine(pluginInstance.settings.note.path, episode); return enforceMaxPathLength(addExtension(filePath, "md")); } -export default async function createPodcastNote( - episode: Episode -): Promise<void> { +export default async function createPodcastNote(episode: Episode): Promise<void> { try { const file = await createPodcastNoteFileIfNotExists(episode); @@ -43,9 +34,7 @@ export default async function createPodcastNote( } } -export async function createPodcastNoteFileIfNotExists( - episode: Episode, -): Promise<TFile> { +export async function createPodcastNoteFileIfNotExists(episode: Episode): Promise<TFile> { const existingFile = getPodcastNote(episode); if (existingFile) { new Notice(`Note for "${episode.title}" already exists`); @@ -56,11 +45,7 @@ export async function createPodcastNoteFileIfNotExists( const filePathDotMd = getPodcastNotePath(episode); const template = pluginInstance.settings.note.template; const chapters = await getTemplateChapters(template, episode); - const content = NoteTemplateEngine( - template, - episode, - { chapters }, - ); + const content = NoteTemplateEngine(template, episode, { chapters }); return await createFileIfNotExists(filePathDotMd, content, episode); } diff --git a/src/download/mediaSignatures.ts b/src/download/mediaSignatures.ts index 2d52000a..6082e992 100644 --- a/src/download/mediaSignatures.ts +++ b/src/download/mediaSignatures.ts @@ -19,8 +19,7 @@ function detectIsoBmffExtension(arr: Uint8Array): string | null { // Need 4 bytes of box size + 'ftyp' + the 4-byte major brand. if (arr.length < 12) return null; - const isFtyp = - arr[4] === 0x66 && arr[5] === 0x74 && arr[6] === 0x79 && arr[7] === 0x70; + const isFtyp = arr[4] === 0x66 && arr[5] === 0x74 && arr[6] === 0x79 && arr[7] === 0x70; if (!isFtyp) return null; const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]); @@ -65,17 +64,10 @@ export function detectAudioFileExtension(data: ArrayBuffer): string | null { // The ftyp brand lives at offset 8, past every offset-0 signature, so read a // header window long enough to cover both before zero-copy-viewing it. - const maxSignatureLength = Math.max( - 12, - ...audioSignatures.map((sig) => sig.signature.length), - ); + const maxSignatureLength = Math.max(12, ...audioSignatures.map((sig) => sig.signature.length)); // Zero-copy view over just the header bytes — no Blob slice, no FileReader, // no extra full-file allocation. - const arr = new Uint8Array( - data, - 0, - Math.min(maxSignatureLength, data.byteLength), - ); + const arr = new Uint8Array(data, 0, Math.min(maxSignatureLength, data.byteLength)); // ISO-BMFF is special-cased first: its brand sits at offset 8, so it can't be // expressed as an offset-0 signature like the entries above. diff --git a/src/download/streaming.test.ts b/src/download/streaming.test.ts index c5ef5212..1464ab03 100644 --- a/src/download/streaming.test.ts +++ b/src/download/streaming.test.ts @@ -110,9 +110,7 @@ describe("probeAndFetchFirstChunk", () => { it("throws on a non-2xx status", async () => { requestUrlMock.mockResolvedValue(res(404, [])); - await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4)).rejects.toThrow( - /HTTP 404/, - ); + await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4)).rejects.toThrow(/HTTP 404/); }); }); @@ -149,7 +147,11 @@ describe("writeStreamedFile", () => { const total = await writeStreamedFile( "https://x/ep.mp3", "out.mp3", - probe({ firstChunk: new Uint8Array([1, 2, 3]).buffer, supportsRange: false, totalSize: 3 }), + probe({ + firstChunk: new Uint8Array([1, 2, 3]).buffer, + supportsRange: false, + totalSize: 3, + }), undefined, 2, ); @@ -162,9 +164,7 @@ describe("writeStreamedFile", () => { it("stops on a short chunk when the total size is unknown (EOF heuristic)", async () => { const a = setupAdapter(); - requestUrlMock - .mockResolvedValueOnce(res(206, [3, 4])) - .mockResolvedValueOnce(res(206, [5])); + requestUrlMock.mockResolvedValueOnce(res(206, [3, 4])).mockResolvedValueOnce(res(206, [5])); const total = await writeStreamedFile( "https://x/ep.mp3", @@ -197,9 +197,9 @@ describe("download size cap (resource exhaustion)", () => { }), ); - await expect( - probeAndFetchFirstChunk("https://x/ep.mp3", 4, 100), - ).rejects.toThrow(/maximum allowed size/); + await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4, 100)).rejects.toThrow( + /maximum allowed size/, + ); }); it("rejects a 200 fallback whose whole body exceeds the cap", async () => { @@ -208,9 +208,9 @@ describe("download size cap (resource exhaustion)", () => { res(200, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], { "content-type": "audio/mpeg" }), ); - await expect( - probeAndFetchFirstChunk("https://x/ep.mp3", 4, 5), - ).rejects.toThrow(/maximum allowed size/); + await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4, 5)).rejects.toThrow( + /maximum allowed size/, + ); }); it("aborts the 206 loop once the running total exceeds the cap (unknown-total infinite stream)", async () => { @@ -231,7 +231,7 @@ describe("download size cap (resource exhaustion)", () => { ).rejects.toThrow(/maximum allowed size/); // It stopped instead of writing without bound. - expect((a.writes.get("out.mp3")?.length ?? 0)).toBeLessThanOrEqual(5 + 2); + expect(a.writes.get("out.mp3")?.length ?? 0).toBeLessThanOrEqual(5 + 2); }); it("rejects when even the first chunk already exceeds the cap", async () => { @@ -241,7 +241,10 @@ describe("download size cap (resource exhaustion)", () => { writeStreamedFile( "https://x/ep.mp3", "out.mp3", - probe({ firstChunk: new Uint8Array([1, 2, 3, 4, 5, 6]).buffer, supportsRange: false }), + probe({ + firstChunk: new Uint8Array([1, 2, 3, 4, 5, 6]).buffer, + supportsRange: false, + }), undefined, 2, 5, @@ -320,10 +323,7 @@ describe("moveIntoPlace", () => { await moveIntoPlace("dir/.tok.ep.mp3.podnotes-partial", "dir/ep.mp3"); - expect(rename).toHaveBeenCalledWith( - "dir/.tok.ep.mp3.podnotes-partial", - "dir/ep.mp3", - ); + expect(rename).toHaveBeenCalledWith("dir/.tok.ep.mp3.podnotes-partial", "dir/ep.mp3"); }); }); @@ -344,10 +344,7 @@ describe("sweepStalePartials", () => { "Podcasts/Ep.mp3", // real file -> keep ]); - await sweepStalePartials( - "Podcasts", - (p) => p === "Podcasts/.Ep.live.podnotes-partial", - ); + await sweepStalePartials("Podcasts", (p) => p === "Podcasts/.Ep.live.podnotes-partial"); expect(s.remove).toHaveBeenCalledTimes(1); expect(s.remove).toHaveBeenCalledWith("Podcasts/.Ep.dead.podnotes-partial"); @@ -361,8 +358,6 @@ describe("sweepStalePartials", () => { app: { vault: { adapter: { writeBinary: vi.fn(), list, remove: vi.fn() } } }, } as unknown as PodNotes); - await expect( - sweepStalePartials("Podcasts", () => false), - ).resolves.toBeUndefined(); + await expect(sweepStalePartials("Podcasts", () => false)).resolves.toBeUndefined(); }); }); diff --git a/src/download/streaming.ts b/src/download/streaming.ts index ec3c88da..cf7f290e 100644 --- a/src/download/streaming.ts +++ b/src/download/streaming.ts @@ -38,16 +38,13 @@ export const MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024; // 2 GiB function tooLargeError(maxSize: number): Error { const maxMb = Math.round(maxSize / (1024 * 1024)); - return new Error( - `Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`, - ); + return new Error(`Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`); } // Obsidian's DataAdapter typings declare `appendBinary` as always present // (public since API 1.13), but our minAppVersion predates its availability, // so we re-declare it as optional and runtime-guard every call site. -export interface BinaryAppendAdapter - extends Omit<DataAdapter, "appendBinary"> { +export interface BinaryAppendAdapter extends Omit<DataAdapter, "appendBinary"> { appendBinary?(path: string, data: ArrayBuffer): Promise<void>; } @@ -65,10 +62,7 @@ export function appendableAdapter(): BinaryAppendAdapter { return get(plugin).app.vault.adapter as unknown as BinaryAppendAdapter; } -function readHeader( - headers: Record<string, string> | undefined, - name: string, -): string | undefined { +function readHeader(headers: Record<string, string> | undefined, name: string): string | undefined { if (!headers) return undefined; const direct = headers[name] ?? headers[name.toLowerCase()]; if (direct !== undefined) return direct; @@ -190,9 +184,7 @@ export async function writeStreamedFile( if (response.status === 416) break; // requested past end of file if (response.status !== 206) { - throw new Error( - `Range request failed (HTTP ${response.status}) at byte ${written}.`, - ); + throw new Error(`Range request failed (HTTP ${response.status}) at byte ${written}.`); } const chunk = response.arrayBuffer; @@ -252,10 +244,7 @@ export function isPartialPath(path: string): boolean { // file, an in-place metadata move that buffers zero bytes — preserving #113's // memory win. (We never finalize by reading the temp back into memory and // re-writing it: that whole-file buffer is exactly the #113 OOM this path avoids.) -export async function moveIntoPlace( - tmpPath: string, - filePath: string, -): Promise<void> { +export async function moveIntoPlace(tmpPath: string, filePath: string): Promise<void> { await appendableAdapter().rename(tmpPath, filePath); } @@ -278,9 +267,6 @@ export async function sweepStalePartials( } } } catch (error) { - console.error( - `Failed to sweep stale download temp files in "${folder}":`, - error, - ); + console.error(`Failed to sweep stale download temp files in "${folder}":`, error); } } diff --git a/src/downloadEpisode.test.ts b/src/downloadEpisode.test.ts index 5b3181c3..32b23be8 100644 --- a/src/downloadEpisode.test.ts +++ b/src/downloadEpisode.test.ts @@ -92,8 +92,7 @@ function setupVault({ streaming = false }: { streaming?: boolean } = {}) { const app = { vault: { - getAbstractFileByPath: (path: string) => - present.has(path) ? new TFile() : null, + getAbstractFileByPath: (path: string) => (present.has(path) ? new TFile() : null), createBinary, createFolder, delete: deleteFile, @@ -194,9 +193,9 @@ describe("downloadEpisodeWithNotice (download command path)", () => { mediaType: "video", }); - await expect( - downloadEpisodeWithNotice(episode, "Podcasts/{{title}}"), - ).rejects.toThrow("Not a playable media file"); + await expect(downloadEpisodeWithNotice(episode, "Podcasts/{{title}}")).rejects.toThrow( + "Not a playable media file", + ); expect(createBinary).not.toHaveBeenCalled(); expect(get(downloadedEpisodes)["Pod"]).toBeUndefined(); @@ -216,9 +215,9 @@ describe("downloadEpisodeWithNotice (download command path)", () => { mediaType: "video", }); - await expect( - downloadEpisodeWithNotice(episode, "Podcasts/{{title}}"), - ).rejects.toThrow("Not a playable media file"); + await expect(downloadEpisodeWithNotice(episode, "Podcasts/{{title}}")).rejects.toThrow( + "Not a playable media file", + ); expect(createBinary).not.toHaveBeenCalled(); expect(get(downloadedEpisodes)["Pod"]).toBeUndefined(); @@ -247,10 +246,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith( - "Podcasts/Ogg Video Title.ogv", - buffer, - ); + expect(createBinary).toHaveBeenCalledWith("Podcasts/Ogg Video Title.ogv", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Ogg Video Title", @@ -283,10 +279,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith( - "Podcasts/Generic Ogg Video.ogv", - buffer, - ); + expect(createBinary).toHaveBeenCalledWith("Podcasts/Generic Ogg Video.ogv", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded?.filePath).toBe("Podcasts/Generic Ogg Video.ogv"); expect(recorded?.mediaType).toBe("video"); @@ -315,10 +308,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith( - "Podcasts/Audio MP4 Title.m4a", - buffer, - ); + expect(createBinary).toHaveBeenCalledWith("Podcasts/Audio MP4 Title.m4a", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Audio MP4 Title", @@ -334,7 +324,18 @@ describe("downloadEpisodeWithNotice (download command path)", () => { // detectAudioFileExtension returns "mp4" for this; for an audio download it // must still be saved as m4a so it isn't treated as an ambiguous container. const buffer = bytes( - 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32, + 0x00, + 0x00, + 0x00, + 0x20, + 0x66, + 0x74, + 0x79, + 0x70, + 0x6d, + 0x70, + 0x34, + 0x32, ); requestUrlMock.mockResolvedValue({ status: 200, @@ -356,10 +357,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith( - "Podcasts/Brandy MP4 Title.m4a", - buffer, - ); + expect(createBinary).toHaveBeenCalledWith("Podcasts/Brandy MP4 Title.m4a", buffer); }); it("preserves audio/webm downloads as audio WebM files", async () => { @@ -385,10 +383,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith( - "Podcasts/Audio WebM Title.webm", - buffer, - ); + expect(createBinary).toHaveBeenCalledWith("Podcasts/Audio WebM Title.webm", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Audio WebM Title", @@ -469,8 +464,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { requestUrlMock.mockResolvedValue({ status: 200, headers: { "content-type": "text/html; charset=utf-8" }, - arrayBuffer: new TextEncoder().encode("<html>login required</html>") - .buffer, + arrayBuffer: new TextEncoder().encode("<html>login required</html>").buffer, } as unknown as Awaited<ReturnType<typeof requestUrl>>); const setTimeoutSpy = vi .spyOn(globalThis, "setTimeout") @@ -638,10 +632,7 @@ describe("downloadEpisode (API path)", () => { const path = await downloadEpisode(episode, "Podcasts/{{title}}"); expect(path).toBe("Podcasts/API Ogg Video.ogv"); - expect(createBinary).toHaveBeenCalledWith( - "Podcasts/API Ogg Video.ogv", - buffer, - ); + expect(createBinary).toHaveBeenCalledWith("Podcasts/API Ogg Video.ogv", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded?.filePath).toBe("Podcasts/API Ogg Video.ogv"); expect(recorded?.mediaType).toBe("video"); @@ -654,9 +645,7 @@ describe("safeDownloadBasename (#183)", () => { }); it("replaces an empty trailing segment but keeps the folder", () => { - expect(safeDownloadBasename("Downloads/", makeEpisode())).toBe( - "Downloads/My Title", - ); + expect(safeDownloadBasename("Downloads/", makeEpisode())).toBe("Downloads/My Title"); }); it("drops a stray leading slash instead of writing an absolute path", () => { @@ -664,15 +653,13 @@ describe("safeDownloadBasename (#183)", () => { }); it("falls back to 'episode' when the title is empty/all-illegal", () => { - expect(safeDownloadBasename("", makeEpisode({ title: "??" }))).toBe( - "episode", - ); + expect(safeDownloadBasename("", makeEpisode({ title: "??" }))).toBe("episode"); }); it("leaves a valid per-episode template untouched", () => { - expect( - safeDownloadBasename("podcast/{{podcast}}/{{title}}", makeEpisode()), - ).toBe("podcast/Pod/My Title"); + expect(safeDownloadBasename("podcast/{{podcast}}/{{title}}", makeEpisode())).toBe( + "podcast/Pod/My Title", + ); }); }); @@ -690,9 +677,9 @@ describe("safeDownloadFilePath (#22)", () => { }); it("leaves a short path unchanged", () => { - expect( - safeDownloadFilePath("podcast/{{podcast}}/{{title}}", makeEpisode(), "mp3"), - ).toBe("podcast/Pod/My Title.mp3"); + expect(safeDownloadFilePath("podcast/{{podcast}}/{{title}}", makeEpisode(), "mp3")).toBe( + "podcast/Pod/My Title.mp3", + ); }); }); @@ -704,8 +691,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { const dot = path.lastIndexOf("."); const slash = path.lastIndexOf("/"); (file as { path: string }).path = path; - (file as { extension: string }).extension = - dot > slash ? path.slice(dot + 1) : ""; + (file as { extension: string }).extension = dot > slash ? path.slice(dot + 1) : ""; (file as { basename: string }).basename = path.slice( slash + 1, dot > slash ? dot : undefined, @@ -739,8 +725,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { files.clear(); const app = { vault: { - getAbstractFileByPath: (path: string) => - files.has(path) ? makeTFile(path) : null, + getAbstractFileByPath: (path: string) => (files.has(path) ? makeTFile(path) : null), readBinary: async (file: TFile) => files.get(file.path), }, }; @@ -849,11 +834,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { streamUrl: "https://cdn.example.com/download?id=123&token=OLD&exp=1", }); seedFile("Podcasts/extensionless-token.m4a", "CACHED-EXTENSIONLESS-AUDIO"); - downloadedEpisodes.addEpisode( - downloaded, - "Podcasts/extensionless-token.m4a", - 20, - ); + downloadedEpisodes.addEpisode(downloaded, "Podcasts/extensionless-token.m4a", 20); const current = episode({ title: "Extensionless Token Episode", @@ -926,8 +907,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { Promise.resolve({ status: 200, headers: { "content-type": "text/html" }, - arrayBuffer: new TextEncoder().encode("<html>login required</html>") - .buffer, + arrayBuffer: new TextEncoder().encode("<html>login required</html>").buffer, }) as unknown as ReturnType<typeof requestUrl>, ); const ep = episode({ @@ -997,8 +977,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { Promise.resolve({ status: 200, headers: { "content-type": "video/mp4" }, - arrayBuffer: new TextEncoder().encode("VIDEO-TYPED-AUDIO-MP4") - .buffer, + arrayBuffer: new TextEncoder().encode("VIDEO-TYPED-AUDIO-MP4").buffer, }) as unknown as ReturnType<typeof requestUrl>, ); const ep = episode({ @@ -1019,8 +998,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { Promise.resolve({ status: 200, headers: { "content-type": "video/webm" }, - arrayBuffer: new TextEncoder().encode("VIDEO-TYPED-AUDIO-WEBM") - .buffer, + arrayBuffer: new TextEncoder().encode("VIDEO-TYPED-AUDIO-WEBM").buffer, }) as unknown as ReturnType<typeof requestUrl>, ); const ep = episode({ @@ -1090,9 +1068,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { streamUrl: "https://cdn.example.com/watch?id=old", }); - await expect(getEpisodeAudioBuffer(current)).rejects.toThrow( - /audio episodes only/i, - ); + await expect(getEpisodeAudioBuffer(current)).rejects.toThrow(/audio episodes only/i); expect(requestUrlMock).not.toHaveBeenCalled(); }); @@ -1109,9 +1085,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { streamUrl: "https://cdn.example.com/watch?id=old", }); - await expect(getEpisodeAudioBuffer(current)).rejects.toThrow( - /audio episodes only/i, - ); + await expect(getEpisodeAudioBuffer(current)).rejects.toThrow(/audio episodes only/i); expect(requestUrlMock).not.toHaveBeenCalled(); }); @@ -1167,11 +1141,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { streamUrl: "https://cdn.example.com/legacy-episode.mp4", }); seedFile("Podcasts/legacy-audio-mp4.mp4", "LEGACY-AUDIO-MP4-BYTES"); - downloadedEpisodes.addEpisode( - downloaded, - "Podcasts/legacy-audio-mp4.mp4", - 20, - ); + downloadedEpisodes.addEpisode(downloaded, "Podcasts/legacy-audio-mp4.mp4", 20); const current = episode({ title: "Legacy Audio MP4 Download", @@ -1200,9 +1170,7 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { } as Partial<LocalEpisode>) as LocalEpisode; seedFile("Local/video.webm", "VIDEO-BYTES"); - await expect(getEpisodeAudioBuffer(local)).rejects.toThrow( - /audio episodes only/i, - ); + await expect(getEpisodeAudioBuffer(local)).rejects.toThrow(/audio episodes only/i); expect(requestUrlMock).not.toHaveBeenCalled(); }); @@ -1248,11 +1216,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { // A server that returns short bodies regardless of the requested range size // lets us exercise multi-chunk streaming without allocating real 4 MiB buffers // (the loop advances by the ACTUAL bytes returned, not the requested span). - function rangeResponse( - status: number, - body: number[], - headers: Record<string, string> = {}, - ) { + function rangeResponse(status: number, body: number[], headers: Record<string, string> = {}) { return { status, headers, @@ -1307,9 +1271,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { const [writePath] = v.writeBinary.mock.calls[0]; const [appendPath] = v.appendBinary.mock.calls[0]; expect(writePath).toBe(appendPath); - expect(writePath).toMatch( - /^Podcasts\/\..*\.My Title\.mp3\.podnotes-partial$/, - ); + expect(writePath).toMatch(/^Podcasts\/\..*\.My Title\.mp3\.podnotes-partial$/); expect(writePath).not.toBe("Podcasts/My Title.mp3"); // Exactly one move into the real path; the temp does not survive. diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index f5cad1f1..551c82d0 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -65,15 +65,11 @@ async function downloadFile(url: string): Promise<DownloadedFile> { const data = response.arrayBuffer; const contentType = - response.headers["content-type"] ?? - response.headers["Content-Type"] ?? - ""; + response.headers["content-type"] ?? response.headers["Content-Type"] ?? ""; return { data, contentType, byteLength: data.byteLength }; } catch (error: unknown) { - throw new Error( - `Failed to download ${url}:\n\n${getErrorMessage(error)}`, - ); + throw new Error(`Failed to download ${url}:\n\n${getErrorMessage(error)}`); } } @@ -100,8 +96,7 @@ function resolveDownloadTarget( headerBytes: ArrayBuffer, contentType: string, ): { extension: string; filePath: string } { - const extension = - inferFileExtensionFromDownload(episode, headerBytes, contentType) ?? "mp3"; + const extension = inferFileExtensionFromDownload(episode, headerBytes, contentType) ?? "mp3"; if (!downloadAppearsPlayable(contentType, extension, episode.mediaType)) { throw new Error("Not a playable media file"); } @@ -127,11 +122,7 @@ async function downloadEpisodeToDisk( // the final path when the URL's extension is wrong — both still handled below. const urlExtension = getUrlExtension(episode.streamUrl); if (urlExtension) { - const provisionalPath = safeDownloadFilePath( - downloadPathTemplate, - episode, - urlExtension, - ); + const provisionalPath = safeDownloadFilePath(downloadPathTemplate, episode, urlExtension); const cached = app.vault.getAbstractFileByPath(provisionalPath); if (cached instanceof TFile) { downloadedEpisodes.addEpisode(episode, provisionalPath, cached.stat.size); @@ -141,8 +132,7 @@ async function downloadEpisodeToDisk( const adapter = appendableAdapter(); const canStream = - typeof adapter.writeBinary === "function" && - typeof adapter.appendBinary === "function"; + typeof adapter.writeBinary === "function" && typeof adapter.appendBinary === "function"; if (!canStream) { const { data, contentType } = await downloadFile(episode.streamUrl); @@ -183,21 +173,13 @@ async function downloadEpisodeToDisk( // Reclaim temps orphaned by a previous download killed mid-stream (the very // crash this fix addresses). The active set protects any concurrent // download's live temp from being swept. - await sweepStalePartials( - parentFolderPath(filePath), - (path) => activePartialPaths.has(path), + await sweepStalePartials(parentFolderPath(filePath), (path) => + activePartialPaths.has(path), ); - const total = await writeStreamedFile( - episode.streamUrl, - tmpPath, - probe, - onProgress, - ); + const total = await writeStreamedFile(episode.streamUrl, tmpPath, probe, onProgress); if (probe.totalSize !== null && total !== probe.totalSize) { - throw new Error( - `Incomplete download: got ${total} of ${probe.totalSize} bytes.`, - ); + throw new Error(`Incomplete download: got ${total} of ${probe.totalSize} bytes.`); } // Only a fully-written, size-verified file is exposed to the vault — as one @@ -253,18 +235,11 @@ export default async function downloadEpisodeWithNotice( update((bodyEl) => bodyEl.createEl("p", { text: "Starting download..." })); - const work = downloadEpisodeToDisk( - episode, - downloadPathTemplate, - (written, total) => { - const mb = (written / (1024 * 1024)).toFixed(1); - const pct = - total && total > 0 ? ` ${Math.round((written / total) * 100)}%` : ""; - update((bodyEl) => - bodyEl.createEl("p", { text: `Downloading...${pct} (${mb} MB)` }), - ); - }, - ); + const work = downloadEpisodeToDisk(episode, downloadPathTemplate, (written, total) => { + const mb = (written / (1024 * 1024)).toFixed(1); + const pct = total && total > 0 ? ` ${Math.round((written / total) * 100)}%` : ""; + update((bodyEl) => bodyEl.createEl("p", { text: `Downloading...${pct} (${mb} MB)` })); + }); downloadsInFlight.set(key, work); try { @@ -328,17 +303,13 @@ function createNoticeDoc(title: string) { * title is empty or all-illegal). Leading/interior empty segments are dropped so * a stray slash can never yield an absolute-looking or double-slashed path. */ -export function safeDownloadBasename( - downloadPathTemplate: string, - episode: Episode, -): string { +export function safeDownloadBasename(downloadPathTemplate: string, episode: Episode): string { const resolved = DownloadPathTemplateEngine(downloadPathTemplate, episode); const segments = resolved.split("/"); const lastIndex = segments.length - 1; if (segments[lastIndex].trim() === "") { - segments[lastIndex] = - replaceIllegalFileNameCharactersInString(episode.title) || "episode"; + segments[lastIndex] = replaceIllegalFileNameCharactersInString(episode.title) || "episode"; } return segments @@ -383,9 +354,7 @@ async function createEpisodeFile({ try { await app.vault.createBinary(filePath, data); } catch (error: unknown) { - throw new Error( - `Failed to write file "${filePath}": ${getErrorMessage(error)}`, - ); + throw new Error(`Failed to write file "${filePath}": ${getErrorMessage(error)}`); } downloadedEpisodes.addEpisode(episode, filePath, data.byteLength); @@ -471,10 +440,7 @@ function getLocalFilePathFromLink(link: string): string | null { return directFile.path; } - const linkedFile = app.metadataCache?.getFirstLinkpathDest( - normalizedTarget, - "", - ); + const linkedFile = app.metadataCache?.getFirstLinkpathDest(normalizedTarget, ""); if (linkedFile instanceof TFile) { return linkedFile.path; } @@ -488,10 +454,7 @@ function inferFileExtensionFromDownload( contentType: string, ): string | null { const contentTypeExtension = getExtensionFromContentType(contentType); - if ( - getMediaTypeFromContentType(contentType) === "video" && - contentTypeExtension - ) { + if (getMediaTypeFromContentType(contentType) === "video" && contentTypeExtension) { return contentTypeExtension; } @@ -511,13 +474,10 @@ function inferFileExtensionFromDownload( // (Codex review #213). The hint comes from the content type or, failing // that, the episode's known media type. const isAudioDownload = - getMediaTypeFromContentType(contentType) === "audio" || - episode.mediaType === "audio"; + getMediaTypeFromContentType(contentType) === "audio" || episode.mediaType === "audio"; return ( - normalizeAudioExtension( - signatureExtension, - isAudioDownload ? "audio" : undefined, - ) ?? signatureExtension + normalizeAudioExtension(signatureExtension, isAudioDownload ? "audio" : undefined) ?? + signatureExtension ); } @@ -579,9 +539,7 @@ function downloadAppearsPlayable( } return ( - normalizedType === "" || - contentMediaType === "audio" || - isPlayableMediaExtension(extension) + normalizedType === "" || contentMediaType === "audio" || isPlayableMediaExtension(extension) ); } @@ -596,10 +554,7 @@ function downloadAppearsAudio( const contentMediaType = getMediaTypeFromContentType(contentType); if (contentMediaType) { - return ( - contentMediaType === "audio" || - isExplicitAudioContainer(extension, mediaTypeHint) - ); + return contentMediaType === "audio" || isExplicitAudioContainer(extension, mediaTypeHint); } const normalizedType = contentType.toLowerCase(); @@ -621,10 +576,7 @@ function normalizeAudioExtension( extension: string | null, mediaTypeHint?: EpisodeMediaType, ): string | null { - if ( - mediaTypeHint === "audio" && - extension?.toLowerCase() === "mp4" - ) { + if (mediaTypeHint === "audio" && extension?.toLowerCase() === "mp4") { return "m4a"; } @@ -671,24 +623,13 @@ export async function downloadEpisode( try { const { data, contentType } = await downloadFile(episode.streamUrl); const inferredExtension = - inferFileExtensionFromDownload(episode, data, contentType) ?? - provisionalExtension; - - if ( - !downloadAppearsPlayable( - contentType, - inferredExtension, - episode.mediaType, - ) - ) { + inferFileExtensionFromDownload(episode, data, contentType) ?? provisionalExtension; + + if (!downloadAppearsPlayable(contentType, inferredExtension, episode.mediaType)) { throw new Error("Not a playable media file."); } - const filePath = safeDownloadFilePath( - downloadPathTemplate, - episode, - inferredExtension, - ); + const filePath = safeDownloadFilePath(downloadPathTemplate, episode, inferredExtension); const finalExistingFile = app.vault.getAbstractFileByPath(filePath); if (finalExistingFile instanceof TFile) { return filePath; @@ -703,9 +644,7 @@ export async function downloadEpisode( return filePath; } catch (error: unknown) { - throw new Error( - `Failed to download ${episode.title}: ${getErrorMessage(error)}`, - ); + throw new Error(`Failed to download ${episode.title}: ${getErrorMessage(error)}`); } } @@ -723,9 +662,7 @@ async function getFileExtension(url: string): Promise<string> { throw: false, }); const contentType = - response.headers["content-type"] ?? - response.headers["Content-Type"] ?? - null; + response.headers["content-type"] ?? response.headers["Content-Type"] ?? null; const extensionFromContentType = getExtensionFromContentType(contentType); if (extensionFromContentType) { @@ -787,10 +724,7 @@ export async function getEpisodeAudioBuffer( // cache; a genuinely different episode has a different path and falls through // to a fresh fetch — correct, just not cached. const registered = downloadedEpisodes.getEpisode(episode); - if ( - registered?.filePath && - isSameMediaSource(registered.streamUrl, episode.streamUrl) - ) { + if (registered?.filePath && isSameMediaSource(registered.streamUrl, episode.streamUrl)) { const registeredMediaType = getEpisodeMediaTypeWithContainerHint( registered, audioContainerHint, @@ -807,18 +741,8 @@ export async function getEpisodeAudioBuffer( try { const { data, contentType } = await downloadFile(episode.streamUrl); - const inferredExtension = inferFileExtensionFromDownload( - episode, - data, - contentType, - ); - if ( - !downloadAppearsAudio( - contentType, - inferredExtension, - audioContainerHint, - ) - ) { + const inferredExtension = inferFileExtensionFromDownload(episode, data, contentType); + if (!downloadAppearsAudio(contentType, inferredExtension, audioContainerHint)) { throw new Error( `The downloaded file is not audio (received "${contentType}"). The episode may be unavailable or require re-authentication.`, ); @@ -826,15 +750,11 @@ export async function getEpisodeAudioBuffer( return { buffer: data, - extension: - normalizeAudioExtension(inferredExtension, audioContainerHint) ?? "mp3", - basename: - replaceIllegalFileNameCharactersInString(episode.title) || "episode", + extension: normalizeAudioExtension(inferredExtension, audioContainerHint) ?? "mp3", + basename: replaceIllegalFileNameCharactersInString(episode.title) || "episode", }; } catch (error: unknown) { - throw new Error( - `Failed to fetch ${episode.title}: ${getErrorMessage(error)}`, - ); + throw new Error(`Failed to fetch ${episode.title}: ${getErrorMessage(error)}`); } } @@ -845,9 +765,7 @@ function getAudioContainerHint( if (episodeMediaType !== "audio") return undefined; if (episode.mediaType === "audio") return "audio"; - return isAudioContainerExtension(getUrlExtension(episode.streamUrl)) - ? "audio" - : undefined; + return isAudioContainerExtension(getUrlExtension(episode.streamUrl)) ? "audio" : undefined; } async function readVaultAudio( @@ -860,16 +778,13 @@ async function readVaultAudio( throw new Error(`Unable to read the audio file at "${filePath}".`); } - const fileExtension = - file.extension || getUrlExtension(file.path || filePath) || ""; + const fileExtension = file.extension || getUrlExtension(file.path || filePath) || ""; const explicitAudioContainer = mediaTypeHint === "audio" && - (fileExtension.toLowerCase() === "mp4" || - fileExtension.toLowerCase() === "webm"); + (fileExtension.toLowerCase() === "mp4" || fileExtension.toLowerCase() === "webm"); const mediaType = explicitAudioContainer ? "audio" - : getMediaTypeFromExtension(fileExtension) ?? - getMediaTypeFromPath(file.path || filePath); + : (getMediaTypeFromExtension(fileExtension) ?? getMediaTypeFromPath(file.path || filePath)); if (mediaType !== "audio") { throw new Error(`Unable to read the non-audio file at "${filePath}".`); } diff --git a/src/getContextMenuHandler.test.ts b/src/getContextMenuHandler.test.ts index 10bf7f49..90ec90d3 100644 --- a/src/getContextMenuHandler.test.ts +++ b/src/getContextMenuHandler.test.ts @@ -14,7 +14,10 @@ function audioFile(path: string): TFile { Object.assign(file as unknown as Record<string, unknown>, { path, extension: path.split(".").pop(), - basename: path.split("/").pop()?.replace(/\.[^.]+$/, ""), + basename: path + .split("/") + .pop() + ?.replace(/\.[^.]+$/, ""), stat: { ctime: 0, mtime: 0, size: 1024 }, }); return file; @@ -62,9 +65,7 @@ function fakeMenu() { function setupWorkspace(openLeaves: unknown[]) { const newLeaf = { setViewState: vi.fn().mockResolvedValue(undefined) }; const workspace = { - _fileMenuHandler: null as - | ((menu: unknown, file: unknown, source: string) => void) - | null, + _fileMenuHandler: null as ((menu: unknown, file: unknown, source: string) => void) | null, on(event: string, cb: (menu: unknown, file: unknown, source: string) => void) { if (event === "file-menu") workspace._fileMenuHandler = cb; return { event } as unknown; @@ -78,9 +79,7 @@ function setupWorkspace(openLeaves: unknown[]) { function makeApp(workspace: unknown, file: TFile) { const vault = { - getAbstractFileByPath: vi.fn((path: string) => - path ? audioFile(path) : file, - ), + getAbstractFileByPath: vi.fn((path: string) => (path ? audioFile(path) : file)), getResourcePath: vi.fn((f: TFile) => `app://resource/${f.path}?1`), }; // createMediaUrlObjectFromFilePath reads the global `app`. @@ -102,13 +101,11 @@ async function playFile( ) { getContextMenuHandler(app as never); const menu = fakeMenu(); - ( - workspace._fileMenuHandler as ( - menu: unknown, - file: unknown, - source: string, - ) => void - )(menu, file, "file-explorer-context-menu"); + (workspace._fileMenuHandler as (menu: unknown, file: unknown, source: string) => void)( + menu, + file, + "file-explorer-context-menu", + ); const play = menu.items.find((i) => i.title === title); expect(play).toBeTruthy(); await play?.onClick?.(); @@ -150,12 +147,7 @@ describe("getContextMenuHandler — Play with PodNotes", () => { const { workspace } = setupWorkspace([{}]); const app = makeApp(workspace, file); - const menu = await playFile( - app, - workspace, - file, - "Play as video with PodNotes", - ); + const menu = await playFile(app, workspace, file, "Play as video with PodNotes"); expect(menu.items.map((item) => item.title)).toEqual([ "Play as audio with PodNotes", diff --git a/src/getContextMenuHandler.ts b/src/getContextMenuHandler.ts index 054ef97d..3835110b 100644 --- a/src/getContextMenuHandler.ts +++ b/src/getContextMenuHandler.ts @@ -2,41 +2,29 @@ import type { App, EventRef, Menu, TAbstractFile, WorkspaceLeaf } from "obsidian import { Notice, TFile } from "obsidian"; import { get } from "svelte/store"; import { VIEW_TYPE } from "./constants"; -import { - downloadedEpisodes, - playedEpisodes, - currentEpisode, - viewState, - plugin, -} from "./store"; +import { downloadedEpisodes, playedEpisodes, currentEpisode, viewState, plugin } from "./store"; import type { EpisodeMediaType } from "./types/Episode"; import type { LocalEpisode } from "./types/LocalEpisode"; import { ViewState } from "./types/ViewState"; import { createMediaUrlObjectFromFilePath } from "./utility/createMediaUrlObjectFromFilePath"; -import { - getMediaTypeFromPath, - isAudioContainerExtension, -} from "./utility/mediaType"; +import { getMediaTypeFromPath, isAudioContainerExtension } from "./utility/mediaType"; export default function getContextMenuHandler(app: App): EventRef { - return app.workspace.on( - "file-menu", - (menu: Menu, file: TAbstractFile) => { - if (!(file instanceof TFile)) return; - const mediaType = getMediaTypeFromPath(file.path); - const isAmbiguousContainer = isAudioContainerExtension(file.extension); - if (!mediaType && !isAmbiguousContainer) return; + return app.workspace.on("file-menu", (menu: Menu, file: TAbstractFile) => { + if (!(file instanceof TFile)) return; + const mediaType = getMediaTypeFromPath(file.path); + const isAmbiguousContainer = isAudioContainerExtension(file.extension); + if (!mediaType && !isAmbiguousContainer) return; - if (isAmbiguousContainer) { - addPlayLocalFileItem(menu, app, file, "audio"); - addPlayLocalFileItem(menu, app, file, "video"); - return; - } - - if (!mediaType) return; - addPlayLocalFileItem(menu, app, file, mediaType); + if (isAmbiguousContainer) { + addPlayLocalFileItem(menu, app, file, "audio"); + addPlayLocalFileItem(menu, app, file, "video"); + return; } - ); + + if (!mediaType) return; + addPlayLocalFileItem(menu, app, file, mediaType); + }); } function addPlayLocalFileItem( @@ -120,8 +108,7 @@ function addPlayLocalFileItem( async function revealPodcastView(app: App): Promise<void> { const { workspace } = app; - let leaf: WorkspaceLeaf | null = - workspace.getLeavesOfType(VIEW_TYPE)[0] ?? null; + let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE)[0] ?? null; if (!leaf) { leaf = workspace.getRightLeaf(false); diff --git a/src/getUniversalPodcastLink.test.ts b/src/getUniversalPodcastLink.test.ts index 960ff072..ebac1929 100644 --- a/src/getUniversalPodcastLink.test.ts +++ b/src/getUniversalPodcastLink.test.ts @@ -102,12 +102,8 @@ describe("getUniversalPodcastLink", () => { expect(requestUrlMock).toHaveBeenCalledWith({ url: "https://pod.link/555.json?limit=1000", }); - expect(writeTextMock).toHaveBeenCalledWith( - "https://pod.link/555/episode/ep-123", - ); - expect(noticeMessages()).toContain( - "Universal episode link copied to clipboard.", - ); + expect(writeTextMock).toHaveBeenCalledWith("https://pod.link/555/episode/ep-123"); + expect(noticeMessages()).toContain("Universal episode link copied to clipboard."); }); test("matches a saved feed by normalized url when the key differs", async () => { @@ -124,9 +120,7 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); expect(queryiTunesMock).not.toHaveBeenCalled(); - expect(writeTextMock).toHaveBeenCalledWith( - "https://pod.link/777/episode/ep-123", - ); + expect(writeTextMock).toHaveBeenCalledWith("https://pod.link/777/episode/ep-123"); }); test("prefers a saved feed whose URL matches over the name-keyed entry (#213)", async () => { @@ -150,9 +144,7 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); expect(queryiTunesMock).not.toHaveBeenCalled(); - expect(writeTextMock).toHaveBeenCalledWith( - "https://pod.link/222/episode/ep-123", - ); + expect(writeTextMock).toHaveBeenCalledWith("https://pod.link/222/episode/ep-123"); }); test("falls back to a tolerant iTunes match when no saved collectionId exists", async () => { @@ -168,9 +160,7 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); expect(queryiTunesMock).toHaveBeenCalledWith("Example Show"); - expect(writeTextMock).toHaveBeenCalledWith( - "https://pod.link/999/episode/ep-123", - ); + expect(writeTextMock).toHaveBeenCalledWith("https://pod.link/999/episode/ep-123"); }); test("prefers the feed-URL match over an earlier same-title iTunes result (#213)", async () => { @@ -194,9 +184,7 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); - expect(writeTextMock).toHaveBeenCalledWith( - "https://pod.link/222/episode/ep-123", - ); + expect(writeTextMock).toHaveBeenCalledWith("https://pod.link/222/episode/ep-123"); }); test("shows an actionable notice when the podcast cannot be matched", async () => { @@ -224,9 +212,7 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); - expect(noticeMessages()).not.toContain( - "Universal episode link copied to clipboard.", - ); + expect(noticeMessages()).not.toContain("Universal episode link copied to clipboard."); expect(noticeMessages()).toContain( "Could not copy to clipboard. Episode link: https://pod.link/555/episode/ep-123", ); diff --git a/src/getUniversalPodcastLink.ts b/src/getUniversalPodcastLink.ts index e3baedaa..c32a974c 100644 --- a/src/getUniversalPodcastLink.ts +++ b/src/getUniversalPodcastLink.ts @@ -27,9 +27,7 @@ async function resolveCollectionId( // we fall back to the name key, then a title match (Codex review #213). const savedFeed = (targetUrl - ? Object.values(feeds).find( - (feed) => normalizeFeedUrl(feed.url) === targetUrl, - ) + ? Object.values(feeds).find((feed) => normalizeFeedUrl(feed.url) === targetUrl) : undefined) ?? feeds[podcastName] ?? Object.values(feeds).find((feed) => feed.title === podcastName); @@ -42,8 +40,7 @@ async function resolveCollectionId( const match = (targetUrl ? iTunesResponse.find((pod) => normalizeFeedUrl(pod.url) === targetUrl) - : undefined) ?? - iTunesResponse.find((pod) => pod.title === podcastName); + : undefined) ?? iTunesResponse.find((pod) => pod.title === podcastName); return match?.collectionId; } @@ -67,19 +64,14 @@ export default async function getUniversalPodcastLink(api: IAPI) { }); if (res.status !== 200) { - throw new Error( - `Failed to get response from pod.link: ${podLinkUrl}`, - ); + throw new Error(`Failed to get response from pod.link: ${podLinkUrl}`); } const targetTitle = itunesTitle ?? title; const ep = res.json.episodes.find( - (episode: { - episodeId: string; - title: string; - [key: string]: string; - }) => episode.title === targetTitle, + (episode: { episodeId: string; title: string; [key: string]: string }) => + episode.title === targetTitle, ); if (!ep) { new Notice( diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index d781f55b..e4ef0a60 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -304,23 +304,18 @@ describe("PodNotes onload wiring (#55)", () => { }); it("registers a previous-track Media Session handler for headphone timestamp capture", async () => { - const originalMediaSession = Object.getOwnPropertyDescriptor( - navigator, - "mediaSession", - ); + const originalMediaSession = Object.getOwnPropertyDescriptor(navigator, "mediaSession"); const calls: Array<{ action: string; hasHandler: boolean }> = []; Object.defineProperty(navigator, "mediaSession", { configurable: true, value: { - setActionHandler: vi.fn( - (action: string, handler: (() => void) | null) => { - calls.push({ - action, - hasHandler: typeof handler === "function", - }); - }, - ), + setActionHandler: vi.fn((action: string, handler: (() => void) | null) => { + calls.push({ + action, + hasHandler: typeof handler === "function", + }); + }), }, }); @@ -332,11 +327,7 @@ describe("PodNotes onload wiring (#55)", () => { }); } finally { if (originalMediaSession) { - Object.defineProperty( - navigator, - "mediaSession", - originalMediaSession, - ); + Object.defineProperty(navigator, "mediaSession", originalMediaSession); } else { Reflect.deleteProperty(navigator, "mediaSession"); } @@ -344,23 +335,18 @@ describe("PodNotes onload wiring (#55)", () => { }); it("clears the previous-track Media Session handler on unload", async () => { - const originalMediaSession = Object.getOwnPropertyDescriptor( - navigator, - "mediaSession", - ); + const originalMediaSession = Object.getOwnPropertyDescriptor(navigator, "mediaSession"); const calls: Array<{ action: string; hasHandler: boolean }> = []; Object.defineProperty(navigator, "mediaSession", { configurable: true, value: { - setActionHandler: vi.fn( - (action: string, handler: (() => void) | null) => { - calls.push({ - action, - hasHandler: typeof handler === "function", - }); - }, - ), + setActionHandler: vi.fn((action: string, handler: (() => void) | null) => { + calls.push({ + action, + hasHandler: typeof handler === "function", + }); + }), }, }); @@ -379,11 +365,7 @@ describe("PodNotes onload wiring (#55)", () => { }); } finally { if (originalMediaSession) { - Object.defineProperty( - navigator, - "mediaSession", - originalMediaSession, - ); + Object.defineProperty(navigator, "mediaSession", originalMediaSession); } else { Reflect.deleteProperty(navigator, "mediaSession"); } @@ -422,9 +404,7 @@ describe("PodNotes onload wiring (#55)", () => { const transcribeCmd = commands.find((c) => c.id === "podnotes-transcribe"); expect(transcribeCmd).toBeDefined(); - expect( - (transcribeCmd!.checkCallback as (checking: boolean) => boolean)(true), - ).toBe(false); + expect((transcribeCmd!.checkCallback as (checking: boolean) => boolean)(true)).toBe(false); }); }); @@ -444,9 +424,7 @@ describe("PodNotes.dedupePlayerLeaves", () => { (plugin as unknown as { app: { workspace: typeof workspace } }).app = { workspace, }; - ( - plugin as unknown as { dedupePlayerLeaves: () => void } - ).dedupePlayerLeaves(); + (plugin as unknown as { dedupePlayerLeaves: () => void }).dedupePlayerLeaves(); } it("collapses multiple leaves to one, keeping the first", () => { diff --git a/src/main.ts b/src/main.ts index 8e981ee3..eabfc63c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,13 +17,7 @@ import { } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; import { registerCommands } from "src/commands"; -import { - Notice, - Platform, - Plugin, - type Editor, - type WorkspaceLeaf, -} from "obsidian"; +import { Notice, Platform, Plugin, type Editor, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants"; @@ -40,10 +34,7 @@ import type { IPodNotesSettings } from "./types/IPodNotesSettings"; import type { IPodNotes } from "./types/IPodNotes"; import { TimestampTemplateEngine } from "./TemplateEngine"; import { prepareTimestampForInsertion } from "./utility/prepareTimestampInsertion"; -import { - createPodcastNoteFileIfNotExists, - getPodcastNote, -} from "./createPodcastNote"; +import { createPodcastNoteFileIfNotExists, getPodcastNote } from "./createPodcastNote"; import type PartialAppExtension from "./global"; import podNotesURIHandler from "./URIHandler"; import getContextMenuHandler from "./getContextMenuHandler"; @@ -110,27 +101,20 @@ export default class PodNotes extends Plugin implements IPodNotes { // loadSettings() already sanitized this, so the store stays in sync with // the (repaired) persisted value. episodeListLimit.set(this.settings.episodeListLimit); - volume.set( - Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1)), - ); - playbackRate.set( - normalizePlaybackRate(this.settings.defaultPlaybackRate), - ); + volume.set(Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1))); + playbackRate.set(normalizePlaybackRate(this.settings.defaultPlaybackRate)); // Mirror every store-backed slice of state into settings, and wire the queue // automation that drops the now-playing episode from the up-next queue. Both // are store subscriptions tied to the plugin lifetime; onunload disposes them. - this.storeUnsubscribers.push( - bindStoresToSettings(this), - subscribeQueueToCurrentEpisode(), - ); + this.storeUnsubscribers.push(bindStoresToSettings(this), subscribeQueueToCurrentEpisode()); // Keep the Local Files playlist in sync with downloaded episodes (issue #176). // downloadedEpisodes is the authoritative offline set, so mirror it into the // localFiles playlist that the Podcast grid renders. Svelte's immediate-fire // backfills already-downloaded episodes on load; later changes keep it current. - this.localFilesMirrorUnsubscribe = downloadedEpisodes.subscribe( - (downloaded) => localFiles.syncWithDownloaded(downloaded), + this.localFilesMirrorUnsubscribe = downloadedEpisodes.subscribe((downloaded) => + localFiles.syncWithDownloaded(downloaded), ); this.api = new API(); @@ -170,11 +154,7 @@ export default class PodNotes extends Plugin implements IPodNotes { // whenever the layout changes. This converges (it only acts when >1 exists) // and never fires on a cold start, where only the restored leaf exists, so // its position is preserved. - this.registerEvent( - this.app.workspace.on("layout-change", () => - this.dedupePlayerLeaves(), - ), - ); + this.registerEvent(this.app.workspace.on("layout-change", () => this.dedupePlayerLeaves())); // Persistent, discoverable entry point in the left ribbon. The right // sidebar header can overflow and hide the view's tab icon (the original @@ -213,9 +193,7 @@ export default class PodNotes extends Plugin implements IPodNotes { // Workspace is not ready, schedule a retry this.layoutReadyAttempts++; if (this.layoutReadyAttempts >= this.maxLayoutReadyAttempts) { - console.error( - "Failed to initialize PodNotes layout after maximum attempts", - ); + console.error("Failed to initialize PodNotes layout after maximum attempts"); } else if (!this.layoutReadyRetry) { this.layoutReadyRetry = window.setTimeout(() => { this.layoutReadyRetry = null; @@ -355,19 +333,12 @@ export default class PodNotes extends Plugin implements IPodNotes { getPodcastNote(this.api.podcast) ?? (await createPodcastNoteFileIfNotExists(this.api.podcast)); const content = await this.app.vault.read(file); - const separator = - content.length > 0 && !content.endsWith("\n") ? "\n" : ""; + const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : ""; - await this.app.vault.modify( - file, - `${content}${separator}${textToAppend}`, - ); + await this.app.vault.modify(file, `${content}${separator}${textToAppend}`); return true; } catch (error) { - console.error( - "PodNotes: failed to capture timestamp into episode note", - error, - ); + console.error("PodNotes: failed to capture timestamp into episode note", error); new Notice("Failed to capture timestamp into episode note"); return false; } @@ -387,10 +358,7 @@ export default class PodNotes extends Plugin implements IPodNotes { }); } - private registerMediaSessionAction( - action: MediaSessionActionName, - handler: () => void, - ): void { + private registerMediaSessionAction(action: MediaSessionActionName, handler: () => void): void { const mediaSession = window.navigator?.mediaSession; if (!mediaSession?.setActionHandler) { return; @@ -400,10 +368,7 @@ export default class PodNotes extends Plugin implements IPodNotes { mediaSession.setActionHandler(action, handler); this.mediaSessionActions.push(action); } catch (error) { - console.warn( - `PodNotes: Media Session action "${action}" is not supported`, - error, - ); + console.warn(`PodNotes: Media Session action "${action}" is not supported`, error); } } @@ -461,15 +426,11 @@ export default class PodNotes extends Plugin implements IPodNotes { ...DEFAULT_SETTINGS.download, ...loadedData?.download, }; - this.settings.download.path = migrateDownloadPath( - this.settings.download.path, - ); + this.settings.download.path = migrateDownloadPath(this.settings.download.path); // Normalise the persisted limit so a malformed value (e.g. 0 from an older // data.json) is repaired in the settings object too, not just clamped for // runtime behaviour, and so a later saveSettings() can't re-persist it (#114). - this.settings.episodeListLimit = sanitizeEpisodeListLimit( - this.settings.episodeListLimit, - ); + this.settings.episodeListLimit = sanitizeEpisodeListLimit(this.settings.episodeListLimit); // Repair persisted skip lengths so a cleared field (NaN -> null in JSON) // can't feed the skip arithmetic and corrupt the playback position (PB-02). this.settings.skipBackwardLength = migrateSkipLength( @@ -492,9 +453,7 @@ export default class PodNotes extends Plugin implements IPodNotes { // Backfill the diarization defaults onto the stored transcript object so an // existing user (who has only { path, template } persisted) gets a valid // transcript.diarization instead of undefined (#168). - this.settings.transcript = migrateTranscriptSettings( - loadedData?.transcript, - ); + this.settings.transcript = migrateTranscriptSettings(loadedData?.transcript); // Backfill a partial/legacy feedNote so a missing template can't crash // createFeedNote's `template.replace(...)` (ST-08). this.settings.feedNote = migrateFeedNoteSettings(loadedData?.feedNote); diff --git a/src/opml.test.ts b/src/opml.test.ts index 9876e665..f61f16f2 100644 --- a/src/opml.test.ts +++ b/src/opml.test.ts @@ -116,9 +116,7 @@ describe("exportOPML (IE-02)", () => { describe("importOPML (IE-01)", () => { it("throws on invalid XML (parsererror is detected)", async () => { - const consoleError = vi - .spyOn(console, "error") - .mockImplementation(() => {}); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); await importOPML("<opml><body><outline text='oops'></body></opml>"); @@ -221,9 +219,7 @@ describe("importOPML (IE-01)", () => { // Nothing to fetch, so the 0/0 progress division must not surface as NaN. expect(getFeed).not.toHaveBeenCalled(); expect(noticeMessages.some((m) => m.includes("NaN"))).toBe(false); - expect(noticeMessages.some((m) => m.includes("Saved 0 new podcasts"))).toBe( - true, - ); + expect(noticeMessages.some((m) => m.includes("Saved 0 new podcasts"))).toBe(true); }); it("reports the saved count, not the fetched count, on duplicate titles", async () => { @@ -246,15 +242,11 @@ describe("importOPML (IE-01)", () => { expect(getFeed).toHaveBeenCalledTimes(2); expect(Object.keys(get(savedFeeds))).toEqual(["Same Title"]); // Exactly one was written, so the summary must say "Saved 1", not "Saved 2". - expect(noticeMessages.some((m) => m.includes("Saved 1 new podcasts"))).toBe( + expect(noticeMessages.some((m) => m.includes("Saved 1 new podcasts"))).toBe(true); + expect(noticeMessages.some((m) => m.includes("Saved 2 new podcasts"))).toBe(false); + expect(noticeMessages.some((m) => m.includes("Skipped 1 with duplicate titles"))).toBe( true, ); - expect(noticeMessages.some((m) => m.includes("Saved 2 new podcasts"))).toBe( - false, - ); - expect( - noticeMessages.some((m) => m.includes("Skipped 1 with duplicate titles")), - ).toBe(true); }); it("counts a title-collision against an existing feed as not saved", async () => { @@ -284,12 +276,10 @@ describe("importOPML (IE-01)", () => { expect(Object.keys(get(savedFeeds))).toEqual(["Same Title"]); // The original feed must be preserved, not overwritten. expect(get(savedFeeds)["Same Title"].url).toBe("https://old.test/feed"); - expect(noticeMessages.some((m) => m.includes("Saved 0 new podcasts"))).toBe( + expect(noticeMessages.some((m) => m.includes("Saved 0 new podcasts"))).toBe(true); + expect(noticeMessages.some((m) => m.includes("Skipped 1 with duplicate titles"))).toBe( true, ); - expect( - noticeMessages.some((m) => m.includes("Skipped 1 with duplicate titles")), - ).toBe(true); }); it("reports URL-skipped and title-dropped feeds in distinct counters", async () => { diff --git a/src/opml.ts b/src/opml.ts index e8e40705..42dd5661 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -10,10 +10,7 @@ import { get } from "svelte/store"; * fixed-case `getAttribute("xmlUrl")` silently drops otherwise-valid feeds. * Returns the trimmed value, or null when absent/empty. */ -function getAttributeCaseInsensitive( - node: Element, - name: string, -): string | null { +function getAttributeCaseInsensitive(node: Element, name: string): string | null { const target = name.toLowerCase(); for (let i = 0; i < node.attributes.length; i++) { const attr = node.attributes.item(i); @@ -116,9 +113,7 @@ async function importOPML(opml: string): Promise<void> { const existingSavedFeeds = get(savedFeeds); const newPodcastsToAdd = incompletePodcastsToAdd.filter( (pod) => - !Object.values(existingSavedFeeds).some( - (savedPod) => savedPod.url === pod.url, - ), + !Object.values(existingSavedFeeds).some((savedPod) => savedPod.url === pod.url), ); const notice = TimerNotice("Importing podcasts", "Preparing to import..."); @@ -159,9 +154,7 @@ async function importOPML(opml: string): Promise<void> { notice.stop(); - const validPodcasts = podcasts.filter( - (pod): pod is PodcastFeed => pod !== null, - ); + const validPodcasts = podcasts.filter((pod): pod is PodcastFeed => pod !== null); // The store is keyed by title, so feeds whose title already exists (either // from an earlier import in this batch or a previously saved feed) are @@ -177,8 +170,7 @@ async function importOPML(opml: string): Promise<void> { }); // Feeds skipped before fetching because their URL was already subscribed. - const skippedExisting = - incompletePodcastsToAdd.length - newPodcastsToAdd.length; + const skippedExisting = incompletePodcastsToAdd.length - newPodcastsToAdd.length; // Feeds that fetched fine but collided with an existing/earlier title and // were therefore silently dropped by the title-keyed store above. const droppedDuplicateTitle = validPodcasts.length - savedCount; @@ -208,11 +200,7 @@ async function importOPML(opml: string): Promise<void> { } } -async function exportOPML( - app: App, - feeds: PodcastFeed[], - filePath = "PodNotes_Export.opml", -) { +async function exportOPML(app: App, feeds: PodcastFeed[], filePath = "PodNotes_Export.opml") { const header = `<?xml version="1.0" encoding="utf-8" standalone="no"?>`; const opml = (child: string) => `<opml version="1.0">${child}</opml>`; const head = (child: string) => `<head>${child}</head>`; diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts index 6c4d6962..b3f8ad66 100644 --- a/src/parser/feedParser.test.ts +++ b/src/parser/feedParser.test.ts @@ -372,9 +372,7 @@ describe("FeedParser", () => { describe("getEpisodes", () => { test("parses all valid episodes from a single feed fetch", async () => { - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(sampleRssFeed), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(sampleRssFeed)); const parser = new FeedParser(); const episodes = await parser.getEpisodes("https://example.com/feed.xml"); @@ -389,9 +387,7 @@ describe("FeedParser", () => { }); test("parses episode properties correctly and populates feed metadata", async () => { - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(sampleRssFeed), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(sampleRssFeed)); const parser = new FeedParser(); const episodes = await parser.getEpisodes("https://example.com/feed.xml"); @@ -409,16 +405,12 @@ describe("FeedParser", () => { }); test("parses Podcasting 2.0 chapter URLs from episodes (#47)", async () => { - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(rssFeedWithPodcastChapters), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(rssFeedWithPodcastChapters)); const parser = new FeedParser(); const episodes = await parser.getEpisodes("https://example.com/feed.xml"); - expect(episodes[0].chaptersUrl).toBe( - "https://example.com/chapters.json", - ); + expect(episodes[0].chaptersUrl).toBe("https://example.com/chapters.json"); expect(episodes[1].chaptersUrl).toBeUndefined(); }); @@ -460,9 +452,7 @@ describe("FeedParser", () => { </item> </channel> </rss>`; - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(audioMp4Feed), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(audioMp4Feed)); const parser = new FeedParser(); const episodes = await parser.getEpisodes("https://example.com/feed.xml"); @@ -497,9 +487,7 @@ describe("FeedParser", () => { </item> </channel> </rss>`; - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(untypedAmbiguousFeed), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(untypedAmbiguousFeed)); const parser = new FeedParser(); const episodes = await parser.getEpisodes("https://example.com/feed.xml"); @@ -512,9 +500,7 @@ describe("FeedParser", () => { }); test("filters out invalid episodes missing required fields", async () => { - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(rssFeedWithInvalidItem), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(rssFeedWithInvalidItem)); const parser = new FeedParser(); const episodes = await parser.getEpisodes("https://example.com/feed.xml"); @@ -530,9 +516,7 @@ describe("FeedParser", () => { artworkUrl: "https://example.com/feed-artwork.jpg", }; - mockRequestWithTimeout.mockResolvedValueOnce( - feedResponse(sampleRssFeed), - ); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(sampleRssFeed)); // When constructed with a feed, it skips re-deriving feed metadata. const parser = new FeedParser(mockFeed); diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index 24a0c28e..49ec82b7 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -70,10 +70,7 @@ export default class FeedParser { const link = this.findFeedLink(channel); if (link) feed.link = link; - const description = this.findDirectChildText(channel, [ - "description", - "itunes:summary", - ]); + const description = this.findDirectChildText(channel, ["description", "itunes:summary"]); if (description) feed.description = description; const author = this.findDirectChildText(channel, [ @@ -108,9 +105,7 @@ export default class FeedParser { ); if (itunesImage) return itunesImage; - return ( - directChildren.find((el) => el.tagName.toLowerCase() === "image") ?? null - ); + return directChildren.find((el) => el.tagName.toLowerCase() === "image") ?? null; } /** @@ -153,10 +148,7 @@ export default class FeedParser { * <itunes:summary>. Direct-child scoping keeps it from matching the same tags * nested inside an <item>. */ - private findDirectChildText( - scope: Document | Element, - tagNames: string[], - ): string { + private findDirectChildText(scope: Document | Element, tagNames: string[]): string { const children = Array.from(scope.children); for (const tag of tagNames) { const wanted = tag.toLowerCase(); @@ -203,13 +195,9 @@ export default class FeedParser { const description = descriptionEl?.textContent || ""; const content = contentEl?.textContent || ""; const pubDate = new Date(pubDateEl.textContent as string); - const artworkUrl = - itunesImageEl?.getAttribute("href") || this.feed?.artworkUrl; + const artworkUrl = itunesImageEl?.getAttribute("href") || this.feed?.artworkUrl; const itunesTitle = itunesTitleEl?.textContent; - const episodeNumber = parseEpisodeNumber( - itunesEpisodeEl?.textContent, - title, - ); + const episodeNumber = parseEpisodeNumber(itunesEpisodeEl?.textContent, title); const duration = parseDurationToSeconds(itunesDurationEl?.textContent); const chaptersUrl = chaptersEl?.getAttribute("url") || undefined; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 7896cdbd..7b242143 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -2,20 +2,20 @@ import { requestUrl } from "obsidian"; import type { Episode } from "src/types/Episode"; export abstract class Parser { - url: string; + url: string; - constructor(url: string) { - this.url = url; - } + constructor(url: string) { + this.url = url; + } - public async parse() { - const req = await requestUrl({url: this.url}); - const dp = new DOMParser(); + public async parse() { + const req = await requestUrl({ url: this.url }); + const dp = new DOMParser(); - const body = dp.parseFromString(req.text, "text/html"); - - return this.parsePage(body); - } + const body = dp.parseFromString(req.text, "text/html"); - protected abstract parsePage(page: Document): Episode; + return this.parsePage(body); + } + + protected abstract parsePage(page: Document): Episode; } diff --git a/src/parser/pocketCastsParser.ts b/src/parser/pocketCastsParser.ts index 5c753344..8cdccbba 100644 --- a/src/parser/pocketCastsParser.ts +++ b/src/parser/pocketCastsParser.ts @@ -2,32 +2,34 @@ import type { Episode } from "src/types/Episode"; import { Parser } from "./parser"; export class PocketCastsParser extends Parser { - protected parsePage(page: Document): Episode { - const audioPlayerEl = page.getElementById('audio_player'); - const headingEl = page.getElementsByTagName('h1')[0]; + protected parsePage(page: Document): Episode { + const audioPlayerEl = page.getElementById("audio_player"); + const headingEl = page.getElementsByTagName("h1")[0]; const titleEl = page.querySelector('[property="og:title"]'); const urlEl = page.querySelector('[property="og:url"]'); const descriptionEl = page.querySelector('[property="og:description"]'); - const episodeDateEl = page.getElementById('episode_date'); - const artworkEl = page.getElementsByTagName('img'); - const rssLink = page.getElementsByClassName('rss_button')[0]?.getElementsByTagName('a')[0]; + const episodeDateEl = page.getElementById("episode_date"); + const artworkEl = page.getElementsByTagName("img"); + const rssLink = page.getElementsByClassName("rss_button")[0]?.getElementsByTagName("a")[0]; if (!audioPlayerEl || !headingEl || !titleEl || !episodeDateEl || !artworkEl || !urlEl) { throw new Error("Could not parse podcast"); } - const {title, podcastName} = this.parseTitleAndPodcastName(headingEl.innerText, titleEl.getAttribute('content') || ""); - const url = urlEl?.getAttribute('content') || ""; - const description = descriptionEl?.getAttribute('content') || ""; + const { title, podcastName } = this.parseTitleAndPodcastName( + headingEl.innerText, + titleEl.getAttribute("content") || "", + ); + const url = urlEl?.getAttribute("content") || ""; + const description = descriptionEl?.getAttribute("content") || ""; const content = ""; - const streamUrl = audioPlayerEl?.getAttribute('src'); + const streamUrl = audioPlayerEl?.getAttribute("src"); const episodeDate = episodeDateEl?.textContent; - const artwork = artworkEl?.item(0)?.getAttribute('src') || undefined; + const artwork = artworkEl?.item(0)?.getAttribute("src") || undefined; - - if (!title || !streamUrl) { - throw new Error("Unable to parse Pocket Cast podcast URL."); - } + if (!title || !streamUrl) { + throw new Error("Unable to parse Pocket Cast podcast URL."); + } return { title, @@ -38,15 +40,18 @@ export class PocketCastsParser extends Parser { artworkUrl: artwork, description, content, - feedUrl: rssLink?.getAttribute('href') || undefined, + feedUrl: rssLink?.getAttribute("href") || undefined, }; } - - private parseTitleAndPodcastName(heading: string, meta: string): {title: string, podcastName: string} { + + private parseTitleAndPodcastName( + heading: string, + meta: string, + ): { title: string; podcastName: string } { if (meta.includes(heading)) { - return {title: heading, podcastName: meta.replace(`${heading} - `, "")}; + return { title: heading, podcastName: meta.replace(`${heading} - `, "") }; } - return {title: heading, podcastName: ""}; + return { title: heading, podcastName: "" }; } } diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts index cc60dc8b..8ce87e81 100644 --- a/src/services/FeedCacheService.test.ts +++ b/src/services/FeedCacheService.test.ts @@ -4,11 +4,7 @@ import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { plugin } from "../store"; import type PodNotes from "../main"; -import { - clearFeedCache, - getCachedEpisodes, - setCachedEpisodes, -} from "./FeedCacheService"; +import { clearFeedCache, getCachedEpisodes, setCachedEpisodes } from "./FeedCacheService"; const testFeed: PodcastFeed = { title: "Accidental Tech Podcast", @@ -25,7 +21,9 @@ function createEpisode(number: number): Episode { content: `<p>Episode ${number}</p>`, podcastName: testFeed.title, artworkUrl: testFeed.artworkUrl, - episodeDate: new Date(`2024-01-${String((number % 28) + 1).padStart(2, "0")}T00:00:00.000Z`), + episodeDate: new Date( + `2024-01-${String((number % 28) + 1).padStart(2, "0")}T00:00:00.000Z`, + ), }; } @@ -45,9 +43,7 @@ describe("FeedCacheService", () => { test("persists at most 75 newest episodes per feed (#124 cap)", () => { // Newest-first feed (100 -> 1); the newest 75 are episodes 100..26. - const episodes = Array.from({ length: 100 }, (_, index) => - datedEpisode(100 - index), - ); + const episodes = Array.from({ length: 100 }, (_, index) => datedEpisode(100 - index)); setCachedEpisodes(testFeed, episodes); @@ -56,38 +52,24 @@ describe("FeedCacheService", () => { // Original (newest-first) order is preserved among the retained episodes. expect(cached?.[0]?.title).toBe("Episode 100"); expect(cached?.[74]?.title).toBe("Episode 26"); - expect(cached?.some((episode) => episode.title === "Episode 25")).toBe( - false, - ); - expect(cached?.some((episode) => episode.title === "Episode 1")).toBe( - false, - ); + expect(cached?.some((episode) => episode.title === "Episode 25")).toBe(false); + expect(cached?.some((episode) => episode.title === "Episode 1")).toBe(false); }); test("retains the newest episodes when the feed is oldest-first (#114)", () => { // Oldest-first feed (1 -> 100): the cache must keep the NEWEST 75 (26..100), // not the first 75 in feed order, or a warm-cache Latest Episodes rebuild // would surface stale episodes. - const episodes = Array.from({ length: 100 }, (_, index) => - datedEpisode(index + 1), - ); + const episodes = Array.from({ length: 100 }, (_, index) => datedEpisode(index + 1)); setCachedEpisodes(testFeed, episodes); const cached = getCachedEpisodes(testFeed); expect(cached).toHaveLength(75); - expect(cached?.some((episode) => episode.title === "Episode 100")).toBe( - true, - ); - expect(cached?.some((episode) => episode.title === "Episode 26")).toBe( - true, - ); - expect(cached?.some((episode) => episode.title === "Episode 25")).toBe( - false, - ); - expect(cached?.some((episode) => episode.title === "Episode 1")).toBe( - false, - ); + expect(cached?.some((episode) => episode.title === "Episode 100")).toBe(true); + expect(cached?.some((episode) => episode.title === "Episode 26")).toBe(true); + expect(cached?.some((episode) => episode.title === "Episode 25")).toBe(false); + expect(cached?.some((episode) => episode.title === "Episode 1")).toBe(false); // Original (oldest-first) order is preserved among the retained episodes. expect(cached?.[0]?.title).toBe("Episode 26"); expect(cached?.[74]?.title).toBe("Episode 100"); @@ -194,9 +176,7 @@ describe("App-backed (vault-scoped) storage", () => { setCachedEpisodes(testFeed, episodes); // Written to the App store (vault-scoped), not raw window.localStorage. - expect([...backing.keys()].some((key) => key.includes("feed-cache:v5"))).toBe( - true, - ); + expect([...backing.keys()].some((key) => key.includes("feed-cache:v5"))).toBe(true); // Read back on a COLD module so the read goes through App#loadLocalStorage // rather than the in-module memo. @@ -207,9 +187,7 @@ describe("App-backed (vault-scoped) storage", () => { expect(freshSvc.getCachedEpisodes(testFeed)).toEqual(episodes); freshSvc.clearFeedCache(); - expect([...backing.keys()].some((key) => key.includes("feed-cache:v5"))).toBe( - false, - ); + expect([...backing.keys()].some((key) => key.includes("feed-cache:v5"))).toBe(false); plugin.set(undefined as unknown as PodNotes); }); diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts index 8b94e8a4..6c1130e4 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -105,9 +105,7 @@ function getStorage(): FeedCacheStorage | null { } try { - return typeof window !== "undefined" && window.localStorage - ? window.localStorage - : null; + return typeof window !== "undefined" && window.localStorage ? window.localStorage : null; } catch (error) { console.error("Unable to access localStorage for feed cache:", error); return null; @@ -193,8 +191,7 @@ function persistCache(): void { // Handle quota exceeded error specifically if ( error instanceof DOMException && - (error.name === "QuotaExceededError" || - error.name === "NS_ERROR_DOM_QUOTA_REACHED") + (error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED") ) { console.warn("localStorage quota exceeded, clearing feed cache"); try { @@ -288,9 +285,7 @@ export function setCachedEpisodes(feed: PodcastFeed, episodes: Episode[]): void store[cacheKey] = { updatedAt: Date.now(), - episodes: selectNewestEpisodes(episodes, MAX_EPISODES_PER_FEED).map( - serializeEpisode, - ), + episodes: selectNewestEpisodes(episodes, MAX_EPISODES_PER_FEED).map(serializeEpisode), }; persistCache(); diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts index 7fe40dab..dd6cddc7 100644 --- a/src/services/TranscriptionService.test.ts +++ b/src/services/TranscriptionService.test.ts @@ -11,8 +11,7 @@ const getEpisodeAudioBufferMock = vi.fn(); const transcriptionsCreateMock = vi.fn(); vi.mock("../downloadEpisode", () => ({ - getEpisodeAudioBuffer: (...args: unknown[]) => - getEpisodeAudioBufferMock(...args), + getEpisodeAudioBuffer: (...args: unknown[]) => getEpisodeAudioBufferMock(...args), })); vi.mock("openai", () => ({ @@ -33,11 +32,13 @@ const mockEpisode: Episode = { episodeDate: new Date("2024-01-01"), }; -function createMockPlugin(overrides: { - openAIApiKey?: string; - podcast?: Episode | null; - existingTranscriptPath?: string | null; -} = {}): PodNotes { +function createMockPlugin( + overrides: { + openAIApiKey?: string; + podcast?: Episode | null; + existingTranscriptPath?: string | null; + } = {}, +): PodNotes { const { openAIApiKey = "test-api-key", podcast = mockEpisode, @@ -290,14 +291,12 @@ describe("TranscriptionService", () => { // >20 MB mp3 → two chunks. chunk 0 fails every retry; chunk 1 "succeeds" // but returns empty text. The body is then only an error marker, which // must NOT be saved as a completed transcript. - transcriptionsCreateMock.mockImplementation( - async ({ file }: { file: File }) => { - if (file.name.includes("part0")) { - throw new Error("boom"); - } - return { text: " " }; - }, - ); + transcriptionsCreateMock.mockImplementation(async ({ file }: { file: File }) => { + if (file.name.includes("part0")) { + throw new Error("boom"); + } + return { text: " " }; + }); vi.useFakeTimers(); try { @@ -339,14 +338,12 @@ describe("TranscriptionService", () => { test("keeps an otherwise-good transcript but warns when only some chunks fail", async () => { // A >20 MB mp3 byte-splits into two chunks; fail the second one. - transcriptionsCreateMock.mockImplementation( - async ({ file }: { file: File }) => { - if (file.name.includes("part1")) { - throw new Error("boom"); - } - return { text: "Good chunk." }; - }, - ); + transcriptionsCreateMock.mockImplementation(async ({ file }: { file: File }) => { + if (file.name.includes("part1")) { + throw new Error("boom"); + } + return { text: "Good chunk." }; + }); vi.useFakeTimers(); try { diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 2ba9d758..b6efd2cd 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -82,8 +82,7 @@ export class TranscriptionService { async transcribeCurrentEpisode(): Promise<void> { if (!requiredTranscriptionKeyPresent(this.plugin.settings)) { const diarization = this.plugin.settings.transcript.diarization; - const needsDeepgram = - diarization?.enabled && diarization.provider === "deepgram"; + const needsDeepgram = diarization?.enabled && diarization.provider === "deepgram"; new Notice( needsDeepgram ? "Please add your Deepgram API key in the transcript settings to use Deepgram diarization." @@ -99,20 +98,16 @@ export class TranscriptionService { } const transcriptPath = this.getTranscriptPath(currentEpisode); - const existingFile = - this.plugin.app.vault.getAbstractFileByPath(transcriptPath); + const existingFile = this.plugin.app.vault.getAbstractFileByPath(transcriptPath); if (existingFile instanceof TFile) { - new Notice( - `You've already transcribed this episode - found ${transcriptPath}.`, - ); + new Notice(`You've already transcribed this episode - found ${transcriptPath}.`); return; } const episodeKey = this.getEpisodeKey(currentEpisode); const isAlreadyQueued = - this.pendingEpisodes.some( - (episode) => this.getEpisodeKey(episode) === episodeKey, - ) || this.activeTranscriptions.has(episodeKey); + this.pendingEpisodes.some((episode) => this.getEpisodeKey(episode) === episodeKey) || + this.activeTranscriptions.has(episodeKey); if (isAlreadyQueued) { new Notice("This episode is already queued or transcribing."); @@ -151,20 +146,14 @@ export class TranscriptionService { } private async transcribeEpisode(episode: Episode): Promise<void> { - const notice = TimerNotice( - `Transcription: ${episode.title}`, - "Preparing to transcribe...", - ); + const notice = TimerNotice(`Transcription: ${episode.title}`, "Preparing to transcribe..."); try { const transcriptPath = this.getTranscriptPath(episode); - const existingFile = - this.plugin.app.vault.getAbstractFileByPath(transcriptPath); + const existingFile = this.plugin.app.vault.getAbstractFileByPath(transcriptPath); if (existingFile instanceof TFile) { notice.stop(); - notice.update( - `Transcript already exists - skipped (${transcriptPath}).`, - ); + notice.update(`Transcript already exists - skipped (${transcriptPath}).`); return; } @@ -226,11 +215,7 @@ export class TranscriptionService { const diarization = this.plugin.settings.transcript.diarization; if (diarization?.enabled) { - const segments = await this.diarize( - audio, - diarization.provider, - updateNotice, - ); + const segments = await this.diarize(audio, diarization.provider, updateNotice); if (segments.length === 0) { throw new Error("Diarization returned no speech segments."); } @@ -242,10 +227,7 @@ export class TranscriptionService { updateNotice("Creating audio chunks..."); const files = await createChunkFiles(audio); updateNotice("Starting transcription..."); - const { text, failedChunks } = await this.transcribeChunks( - files, - updateNotice, - ); + const { text, failedChunks } = await this.transcribeChunks(files, updateNotice); // Strip the error placeholders (and trim) to see whether ANY real speech was // transcribed. Nothing real means every chunk failed or the only successes @@ -362,10 +344,7 @@ export class TranscriptionService { } }; - const workerCount = Math.min( - this.MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, - files.length, - ); + const workerCount = Math.min(this.MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, files.length); const workers = Array.from({ length: workerCount }, () => worker()); await Promise.all(workers); @@ -381,16 +360,10 @@ export class TranscriptionService { * the user's chosen suffix. */ private getTranscriptPath(episode: Episode): string { - return getEpisodeTranscriptPath( - episode, - this.plugin.settings.transcript.path, - ); + return getEpisodeTranscriptPath(episode, this.plugin.settings.transcript.path); } - private async saveTranscription( - episode: Episode, - transcriptBody: string, - ): Promise<void> { + private async saveTranscription(episode: Episode, transcriptBody: string): Promise<void> { const transcriptPath = this.getTranscriptPath(episode); // transcriptBody is already formatted by buildTranscriptBody (sentence // reflow for Whisper, speaker turns for diarization), so it is templated @@ -407,10 +380,7 @@ export class TranscriptionService { // "Folder already exists" thrown when a case-insensitive lookup misses an // existing folder, which the old hand-rolled loop surfaced as a spurious // failure (the #87 class of bug). - const directory = transcriptPath.substring( - 0, - transcriptPath.lastIndexOf("/"), - ); + const directory = transcriptPath.substring(0, transcriptPath.lastIndexOf("/")); await ensureFolderExists(directory, vault); const file = vault.getAbstractFileByPath(transcriptPath); diff --git a/src/services/audioChunker.test.ts b/src/services/audioChunker.test.ts index 19133cf6..865d0694 100644 --- a/src/services/audioChunker.test.ts +++ b/src/services/audioChunker.test.ts @@ -224,9 +224,7 @@ describe("createChunkFiles large-file routing (other-logic-bug)", () => { test("decodes a large ogg to standalone WAV chunks instead of byte-splitting", async () => { // Without the fix this returned raw .ogg byte slices that lack stream // headers and can't be decoded standalone. Now it must produce .wav chunks. - stubAudioContext( - (buffer) => new FakeAudioBuffer(Math.ceil(buffer.byteLength / 2)), - ); + stubAudioContext((buffer) => new FakeAudioBuffer(Math.ceil(buffer.byteLength / 2))); const files = await createChunkFiles({ buffer: new ArrayBuffer(CHUNK_SIZE_BYTES + 1024), diff --git a/src/services/audioChunker.ts b/src/services/audioChunker.ts index bb6485a1..c58403c5 100644 --- a/src/services/audioChunker.ts +++ b/src/services/audioChunker.ts @@ -48,8 +48,7 @@ export function getMimeType(fileExtension: string): string { */ export function shouldConvertToWav(extension: string, mimeType: string): boolean { const normalizedExtension = extension.toLowerCase(); - const isMp3 = - normalizedExtension === "mp3" || mimeType.toLowerCase() === "audio/mp3"; + const isMp3 = normalizedExtension === "mp3" || mimeType.toLowerCase() === "audio/mp3"; return !isMp3; } @@ -125,10 +124,7 @@ export function createBinaryChunkFiles( return files; } -async function convertToWavChunks( - buffer: ArrayBuffer, - basename: string, -): Promise<File[]> { +async function convertToWavChunks(buffer: ArrayBuffer, basename: string): Promise<File[]> { const audioContext = createAudioContext(); if (!audioContext) return []; @@ -154,8 +150,7 @@ function createAudioContext(): AudioContext | null { const contextCtor = window.AudioContext || - (window as typeof window & { webkitAudioContext?: typeof AudioContext }) - .webkitAudioContext; + (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!contextCtor) { return null; } @@ -167,25 +162,15 @@ function renderWavChunks(audioBuffer: AudioBuffer, basename: string): File[] { const numChannels = audioBuffer.numberOfChannels; const bytesPerFrame = numChannels * PCM_BYTES_PER_SAMPLE; const availableBytesPerChunk = CHUNK_SIZE_BYTES - WAV_HEADER_SIZE; - const maxSamplesPerChunk = Math.max( - 1, - Math.floor(availableBytesPerChunk / bytesPerFrame), - ); + const maxSamplesPerChunk = Math.max(1, Math.floor(availableBytesPerChunk / bytesPerFrame)); const channelData = Array.from({ length: numChannels }, (_, channelIndex) => audioBuffer.getChannelData(channelIndex), ); const files: File[] = []; let chunkIndex = 0; - for ( - let startSample = 0; - startSample < audioBuffer.length; - startSample += maxSamplesPerChunk - ) { - const endSample = Math.min( - audioBuffer.length, - startSample + maxSamplesPerChunk, - ); + for (let startSample = 0; startSample < audioBuffer.length; startSample += maxSamplesPerChunk) { + const endSample = Math.min(audioBuffer.length, startSample + maxSamplesPerChunk); const wavBuffer = renderWavBuffer( channelData, audioBuffer.sampleRate, diff --git a/src/services/diarization/deepgramProvider.test.ts b/src/services/diarization/deepgramProvider.test.ts index f416ab0d..6e3ccd03 100644 --- a/src/services/diarization/deepgramProvider.test.ts +++ b/src/services/diarization/deepgramProvider.test.ts @@ -9,9 +9,7 @@ const audio: DiarizationAudio = { basename: "episode", }; -function stubResponse( - overrides: Partial<{ status: number; json: unknown; text: string }>, -) { +function stubResponse(overrides: Partial<{ status: number; json: unknown; text: string }>) { return { status: 200, json: {}, @@ -59,9 +57,11 @@ describe("diarizeWithDeepgram (#168)", () => { }); it("throws a helpful error on a non-2xx response", async () => { - const request = vi.fn<RequestUrlFn>().mockResolvedValue( - stubResponse({ status: 401, json: { err_msg: "Invalid credentials" } }), - ); + const request = vi + .fn<RequestUrlFn>() + .mockResolvedValue( + stubResponse({ status: 401, json: { err_msg: "Invalid credentials" } }), + ); await expect( diarizeWithDeepgram({ diff --git a/src/services/diarization/deepgramProvider.ts b/src/services/diarization/deepgramProvider.ts index 96534ba0..4234cd16 100644 --- a/src/services/diarization/deepgramProvider.ts +++ b/src/services/diarization/deepgramProvider.ts @@ -21,8 +21,7 @@ const DEEPGRAM_LISTEN_URL = "https://api.deepgram.com/v1/listen"; * a single whole-file request, so speaker identity stays consistent across the * entire episode (no cross-chunk drift). */ -const DEEPGRAM_QUERY = - "model=nova-3&diarize=true&punctuate=true&smart_format=true&utterances=true"; +const DEEPGRAM_QUERY = "model=nova-3&diarize=true&punctuate=true&smart_format=true&utterances=true"; /** * Diarize with Deepgram's pre-recorded speech-to-text API (issue #168). diff --git a/src/services/diarization/index.ts b/src/services/diarization/index.ts index b75a588a..80f62afb 100644 --- a/src/services/diarization/index.ts +++ b/src/services/diarization/index.ts @@ -14,9 +14,7 @@ export { diarizeWithDeepgram, type RequestUrlFn } from "./deepgramProvider"; * that is certain to fail for a missing key. Pure so it is unit-testable and * usable from both the command callback and the service. */ -export function requiredTranscriptionKeyPresent( - settings: IPodNotesSettings, -): boolean { +export function requiredTranscriptionKeyPresent(settings: IPodNotesSettings): boolean { const diarization = settings.transcript?.diarization; if (diarization?.enabled && diarization.provider === "deepgram") { return Boolean(settings.diarizationApiKey?.trim()); diff --git a/src/services/diarization/openaiProvider.test.ts b/src/services/diarization/openaiProvider.test.ts index 8216d57e..f6f36a5f 100644 --- a/src/services/diarization/openaiProvider.test.ts +++ b/src/services/diarization/openaiProvider.test.ts @@ -3,8 +3,7 @@ import type { OpenAI } from "openai"; import { diarizeWithOpenAI } from "./openaiProvider"; function fakeClient(create: ReturnType<typeof vi.fn>): () => Promise<OpenAI> { - return async () => - ({ audio: { transcriptions: { create } } }) as unknown as OpenAI; + return async () => ({ audio: { transcriptions: { create } } }) as unknown as OpenAI; } function chunk(name: string): File { diff --git a/src/services/diarization/openaiProvider.ts b/src/services/diarization/openaiProvider.ts index 9ca26a1c..d96df3a6 100644 --- a/src/services/diarization/openaiProvider.ts +++ b/src/services/diarization/openaiProvider.ts @@ -33,9 +33,7 @@ export async function diarizeWithOpenAI(opts: { let lastError: unknown; for (let index = 0; index < chunkFiles.length; index++) { - onProgress( - `Diarizing with OpenAI... chunk ${index + 1}/${chunkFiles.length}`, - ); + onProgress(`Diarizing with OpenAI... chunk ${index + 1}/${chunkFiles.length}`); const file = chunkFiles[index]; let attempt = 0; diff --git a/src/services/diarization/requiredTranscriptionKeyPresent.test.ts b/src/services/diarization/requiredTranscriptionKeyPresent.test.ts index 7448a534..40eea65e 100644 --- a/src/services/diarization/requiredTranscriptionKeyPresent.test.ts +++ b/src/services/diarization/requiredTranscriptionKeyPresent.test.ts @@ -20,12 +20,8 @@ function withDiarization( describe("requiredTranscriptionKeyPresent (#168)", () => { it("requires the OpenAI key when diarization is off", () => { - expect(requiredTranscriptionKeyPresent(settings({ openAIApiKey: "sk" }))).toBe( - true, - ); - expect(requiredTranscriptionKeyPresent(settings({ openAIApiKey: "" }))).toBe( - false, - ); + expect(requiredTranscriptionKeyPresent(settings({ openAIApiKey: "sk" }))).toBe(true); + expect(requiredTranscriptionKeyPresent(settings({ openAIApiKey: "" }))).toBe(false); }); it("requires the OpenAI key for the OpenAI diarization provider", () => { @@ -71,9 +67,7 @@ describe("requiredTranscriptionKeyPresent (#168)", () => { }); it("treats whitespace-only keys as absent", () => { - expect( - requiredTranscriptionKeyPresent(settings({ openAIApiKey: " " })), - ).toBe(false); + expect(requiredTranscriptionKeyPresent(settings({ openAIApiKey: " " }))).toBe(false); expect( requiredTranscriptionKeyPresent( withDiarization("deepgram", true, { diarizationApiKey: " " }), diff --git a/src/services/diarization/segments.test.ts b/src/services/diarization/segments.test.ts index 02e8ac9b..d8419900 100644 --- a/src/services/diarization/segments.test.ts +++ b/src/services/diarization/segments.test.ts @@ -12,8 +12,22 @@ describe("parseOpenAIDiarizedSegments (#168)", () => { it("maps diarized_json segments to normalized turns", () => { const payload = { segments: [ - { type: "transcript.text.segment", id: "1", speaker: "A", start: 0, end: 2, text: "Hello there." }, - { type: "transcript.text.segment", id: "2", speaker: "B", start: 2, end: 4, text: "Hi!" }, + { + type: "transcript.text.segment", + id: "1", + speaker: "A", + start: 0, + end: 2, + text: "Hello there.", + }, + { + type: "transcript.text.segment", + id: "2", + speaker: "B", + start: 2, + end: 4, + text: "Hi!", + }, ], }; @@ -87,9 +101,27 @@ describe("parseDeepgramSegments (#168)", () => { alternatives: [ { words: [ - { word: "hello", punctuated_word: "Hello", speaker: 0, start: 0, end: 1 }, - { word: "there", punctuated_word: "there.", speaker: 0, start: 1, end: 2 }, - { word: "hi", punctuated_word: "Hi!", speaker: 1, start: 2, end: 3 }, + { + word: "hello", + punctuated_word: "Hello", + speaker: 0, + start: 0, + end: 1, + }, + { + word: "there", + punctuated_word: "there.", + speaker: 0, + start: 1, + end: 2, + }, + { + word: "hi", + punctuated_word: "Hi!", + speaker: 1, + start: 2, + end: 3, + }, ], }, ], @@ -137,9 +169,7 @@ describe("formatSpeakerLabel (#168)", () => { // `$&`, `$\``, `$'`, `$$` are special in a String.replace replacement string; // the label must be inserted literally rather than expanded to matched text. const speaker = "$`$'$&$$"; - expect(formatSpeakerLabel("**{{speaker}}:** ", speaker)).toBe( - `**${speaker}:** `, - ); + expect(formatSpeakerLabel("**{{speaker}}:** ", speaker)).toBe(`**${speaker}:** `); expect(formatSpeakerLabel("{{speaker}} - ", speaker)).toBe(`${speaker} - `); }); }); diff --git a/src/services/diarization/segments.ts b/src/services/diarization/segments.ts index a009908f..50918228 100644 --- a/src/services/diarization/segments.ts +++ b/src/services/diarization/segments.ts @@ -38,9 +38,7 @@ export function parseOpenAIDiarizedSegments(payload: unknown): DiarizedSegment[] const text = cleanText(raw.text); if (!text) continue; const speaker = - typeof raw.speaker === "string" && raw.speaker.trim() - ? raw.speaker.trim() - : "?"; + typeof raw.speaker === "string" && raw.speaker.trim() ? raw.speaker.trim() : "?"; segments.push({ speaker, text, @@ -119,9 +117,7 @@ function groupDeepgramWords(results: Record<string, unknown>): DiarizedSegment[] } function speakerLabelFromIndex(speaker: unknown): string { - return typeof speaker === "number" && Number.isFinite(speaker) - ? String(speaker + 1) - : "?"; + return typeof speaker === "number" && Number.isFinite(speaker) ? String(speaker + 1) : "?"; } /** @@ -129,9 +125,7 @@ function speakerLabelFromIndex(speaker: unknown): string { * back-to-back segments for the same speaker (e.g. one per sentence); merging * them yields readable paragraphs and a stable shape for rendering. */ -export function mergeAdjacentSpeakers( - segments: DiarizedSegment[], -): DiarizedSegment[] { +export function mergeAdjacentSpeakers(segments: DiarizedSegment[]): DiarizedSegment[] { const merged: DiarizedSegment[] = []; for (const segment of segments) { const last = merged[merged.length - 1]; diff --git a/src/services/diarization/types.ts b/src/services/diarization/types.ts index 535febee..ef1bfa70 100644 --- a/src/services/diarization/types.ts +++ b/src/services/diarization/types.ts @@ -17,10 +17,7 @@ */ export type DiarizationProviderId = "openai" | "deepgram"; -export const DIARIZATION_PROVIDERS: readonly DiarizationProviderId[] = [ - "openai", - "deepgram", -]; +export const DIARIZATION_PROVIDERS: readonly DiarizationProviderId[] = ["openai", "deepgram"]; /** The fixed OpenAI model that performs diarization (no other OpenAI model does). */ export const OPENAI_DIARIZE_MODEL = "gpt-4o-transcribe-diarize"; diff --git a/src/settingsMigrations.test.ts b/src/settingsMigrations.test.ts index 96dab84e..49f4a7b5 100644 --- a/src/settingsMigrations.test.ts +++ b/src/settingsMigrations.test.ts @@ -29,9 +29,7 @@ describe("migrateDownloadPath (#183)", () => { }); it("treats an absent value (undefined/null) as the legacy default", () => { - expect(migrateDownloadPath(undefined)).toBe( - DEFAULT_SETTINGS.download.path, - ); + expect(migrateDownloadPath(undefined)).toBe(DEFAULT_SETTINGS.download.path); // null is reachable via a corrupted/hand-edited data.json; mapping it to the // default also keeps null out of DownloadPathTemplateEngine (would crash). expect(migrateDownloadPath(null)).toBe(DEFAULT_SETTINGS.download.path); @@ -65,9 +63,7 @@ describe("episode note defaults (#160)", () => { // Opens with a YAML frontmatter block... expect(template.startsWith("---\n")).toBe(true); // ...that closes before the body H1. - expect(template.indexOf("\n---\n")).toBeLessThan( - template.indexOf("# {{title}}"), - ); + expect(template.indexOf("\n---\n")).toBeLessThan(template.indexOf("# {{title}}")); // Carries the structured properties Bases sorts/filters on. expect(template).toContain("type: podcastEpisode"); expect(template).toMatch(/^tags:/m); @@ -81,9 +77,7 @@ describe("migrateNoteSettings (#160)", () => { }; it("upgrades the legacy empty note (both fields empty) to the default", () => { - expect(migrateNoteSettings({ path: "", template: "" })).toEqual( - DEFAULT_NOTE, - ); + expect(migrateNoteSettings({ path: "", template: "" })).toEqual(DEFAULT_NOTE); }); it("treats an absent note (undefined/null/empty object) as the legacy default", () => { @@ -96,9 +90,7 @@ describe("migrateNoteSettings (#160)", () => { // A corrupted/hand-edited data.json could carry nulls; they must not reach // the path/template engines (null.replace would throw) and a wholly-empty // note still upgrades. - expect(migrateNoteSettings({ path: null, template: null })).toEqual( - DEFAULT_NOTE, - ); + expect(migrateNoteSettings({ path: null, template: null })).toEqual(DEFAULT_NOTE); }); it("preserves a fully-configured note verbatim", () => { @@ -113,12 +105,14 @@ describe("migrateNoteSettings (#160)", () => { // Custom path + empty template = note creation deliberately disabled; the // empty template must NOT be filled with the new default (would re-enable // the command). Symmetric for a custom template + empty path. - expect( - migrateNoteSettings({ path: "Custom/{{title}}.md", template: "" }), - ).toEqual({ path: "Custom/{{title}}.md", template: "" }); - expect( - migrateNoteSettings({ path: "", template: "## {{title}}" }), - ).toEqual({ path: "", template: "## {{title}}" }); + expect(migrateNoteSettings({ path: "Custom/{{title}}.md", template: "" })).toEqual({ + path: "Custom/{{title}}.md", + template: "", + }); + expect(migrateNoteSettings({ path: "", template: "## {{title}}" })).toEqual({ + path: "", + template: "## {{title}}", + }); }); it("is idempotent on the current default", () => { @@ -144,9 +138,7 @@ describe("migrateTranscriptSettings (#168)", () => { }); it("treats an absent transcript (undefined/null) as all defaults", () => { - expect(migrateTranscriptSettings(undefined)).toEqual( - DEFAULT_SETTINGS.transcript, - ); + expect(migrateTranscriptSettings(undefined)).toEqual(DEFAULT_SETTINGS.transcript); expect(migrateTranscriptSettings(null)).toEqual(DEFAULT_SETTINGS.transcript); }); @@ -200,12 +192,10 @@ describe("migrateFeedNoteSettings (ST-08)", () => { }; it("backfills a missing template on a partial feedNote", () => { - expect(migrateFeedNoteSettings({ path: "Podcasts/{{podcast}}.md" })).toEqual( - { - path: "Podcasts/{{podcast}}.md", - template: DEFAULT_SETTINGS.feedNote.template, - }, - ); + expect(migrateFeedNoteSettings({ path: "Podcasts/{{podcast}}.md" })).toEqual({ + path: "Podcasts/{{podcast}}.md", + template: DEFAULT_SETTINGS.feedNote.template, + }); }); it("treats an absent/empty feedNote as all defaults", () => { @@ -215,9 +205,7 @@ describe("migrateFeedNoteSettings (ST-08)", () => { }); it("coalesces null/non-string fields so they never reach template.replace()", () => { - expect( - migrateFeedNoteSettings({ path: null, template: null }), - ).toEqual(DEFAULT_FEED_NOTE); + expect(migrateFeedNoteSettings({ path: null, template: null })).toEqual(DEFAULT_FEED_NOTE); }); it("preserves a fully-configured feedNote verbatim", () => { diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index 6f6aa34e..f7182454 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -1,8 +1,5 @@ import { DEFAULT_SETTINGS } from "./constants"; -import { - DIARIZATION_PROVIDERS, - type DiarizationProviderId, -} from "./services/diarization/types"; +import { DIARIZATION_PROVIDERS, type DiarizationProviderId } from "./services/diarization/types"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; /** @@ -41,9 +38,7 @@ export const LEGACY_EMPTY_DOWNLOAD_PATH = ""; * to apply the intended value and to keep a `null` from reaching * DownloadPathTemplateEngine, where `null.replace(...)` would throw. */ -export function migrateDownloadPath( - storedPath: string | null | undefined, -): string { +export function migrateDownloadPath(storedPath: string | null | undefined): string { if ( storedPath === undefined || storedPath === null || @@ -131,10 +126,7 @@ export function migrateTranscriptSettings( return { path: typeof stored.path === "string" ? stored.path : defaults.path, - template: - typeof stored.template === "string" - ? stored.template - : defaults.template, + template: typeof stored.template === "string" ? stored.template : defaults.template, diarization: { enabled: typeof storedDiarization.enabled === "boolean" @@ -171,12 +163,8 @@ export function migrateFeedNoteSettings( ): IPodNotesSettings["feedNote"] { const s = stored ?? {}; return { - path: - typeof s.path === "string" ? s.path : DEFAULT_SETTINGS.feedNote.path, - template: - typeof s.template === "string" - ? s.template - : DEFAULT_SETTINGS.feedNote.template, + path: typeof s.path === "string" ? s.path : DEFAULT_SETTINGS.feedNote.path, + template: typeof s.template === "string" ? s.template : DEFAULT_SETTINGS.feedNote.template, }; } diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 2a9dcf60..1a2b425c 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -11,9 +11,7 @@ import { } from "./settingsTransfer"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; -function makeSettings( - overrides: Partial<IPodNotesSettings> = {}, -): IPodNotesSettings { +function makeSettings(overrides: Partial<IPodNotesSettings> = {}): IPodNotesSettings { return structuredClone({ ...DEFAULT_SETTINGS, ...overrides }); } @@ -21,12 +19,7 @@ const NOW = "2026-06-14T00:00:00.000Z"; describe("serializeSettings", () => { it("wraps settings in a versioned envelope", () => { - const envelope = serializeSettings( - makeSettings(), - { includeSecret: false }, - "2.16.0", - NOW, - ); + const envelope = serializeSettings(makeSettings(), { includeSecret: false }, "2.16.0", NOW); expect(envelope.type).toBe(SETTINGS_EXPORT_TYPE); expect(envelope.version).toBe(SETTINGS_EXPORT_VERSION); @@ -36,7 +29,9 @@ describe("serializeSettings", () => { it("excludes runtime/vault-specific state even when populated", () => { const settings = makeSettings({ - playedEpisodes: { ep: { title: "ep", podcastName: "p", time: 1, duration: 2, finished: false } }, + playedEpisodes: { + ep: { title: "ep", podcastName: "p", time: 1, duration: 2, finished: false }, + }, podNotes: { ep: { title: "ep", podcastName: "p" } as never }, downloadedEpisodes: { p: [] }, currentEpisode: { title: "ep" } as never, @@ -168,7 +163,9 @@ describe("parseImport", () => { it("accepts a raw settings object and drops excluded runtime keys", () => { const raw = makeSettings({ defaultVolume: 0.3, - playedEpisodes: { ep: { title: "ep", podcastName: "p", time: 1, duration: 2, finished: true } }, + playedEpisodes: { + ep: { title: "ep", podcastName: "p", time: 1, duration: 2, finished: true }, + }, currentEpisode: { title: "ep" } as never, }); @@ -184,9 +181,7 @@ describe("parseImport", () => { }); it("drops unknown keys", () => { - const result = parseImport( - JSON.stringify({ defaultVolume: 0.5, somethingElse: true }), - ); + const result = parseImport(JSON.stringify({ defaultVolume: 0.5, somethingElse: true })); expect(result.ok).toBe(true); if (result.ok) expect(result.settings).not.toHaveProperty("somethingElse"); }); @@ -208,9 +203,7 @@ describe("parseImport", () => { }); it("drops a wrong-typed nested field but keeps valid siblings", () => { - const result = parseImport( - JSON.stringify({ note: { path: 5, template: "keep" } }), - ); + const result = parseImport(JSON.stringify({ note: { path: 5, template: "keep" } })); expect(result.ok).toBe(true); if (result.ok) { expect(result.settings.note).toEqual({ template: "keep" }); @@ -228,9 +221,7 @@ describe("parseImport", () => { }); it("does not pollute Object.prototype via __proto__", () => { - const result = parseImport( - '{"defaultVolume": 0.5, "__proto__": {"polluted": true}}', - ); + const result = parseImport('{"defaultVolume": 0.5, "__proto__": {"polluted": true}}'); expect(result.ok).toBe(true); expect(({} as Record<string, unknown>).polluted).toBeUndefined(); }); @@ -345,7 +336,9 @@ describe("mergeImportedSettings", () => { it("overrides preferences while preserving excluded runtime state", () => { const current = makeSettings({ defaultVolume: 1, - playedEpisodes: { ep: { title: "ep", podcastName: "p", time: 5, duration: 9, finished: false } }, + playedEpisodes: { + ep: { title: "ep", podcastName: "p", time: 5, duration: 9, finished: false }, + }, }); const merged = mergeImportedSettings(current, { defaultVolume: 0.25 }); @@ -442,16 +435,12 @@ describe("mergeImportedSettings", () => { describe("describeSecrets (#168)", () => { it("names only the secrets that actually hold a value", () => { expect(describeSecrets(makeSettings())).toEqual([]); - expect(describeSecrets(makeSettings({ openAIApiKey: "sk" }))).toEqual([ - "OpenAI API key", + expect(describeSecrets(makeSettings({ openAIApiKey: "sk" }))).toEqual(["OpenAI API key"]); + expect(describeSecrets(makeSettings({ diarizationApiKey: "dg" }))).toEqual([ + "Deepgram API key", ]); expect( - describeSecrets(makeSettings({ diarizationApiKey: "dg" })), - ).toEqual(["Deepgram API key"]); - expect( - describeSecrets( - makeSettings({ openAIApiKey: "sk", diarizationApiKey: "dg" }), - ), + describeSecrets(makeSettings({ openAIApiKey: "sk", diarizationApiKey: "dg" })), ).toEqual(["OpenAI API key", "Deepgram API key"]); }); diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index f11e8171..2955a604 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -52,9 +52,7 @@ const SECRET_KEY_LABELS: Record<string, string> = { * import confirmation honest about which keys are involved — so a Deepgram-only * user is never told only "OpenAI API key" (and vice versa). See issue #168. */ -export function describeSecrets( - settings: Partial<IPodNotesSettings>, -): string[] { +export function describeSecrets(settings: Partial<IPodNotesSettings>): string[] { return [...SECRET_KEYS] .filter((key) => Boolean((settings[key] as string | undefined)?.trim())) .map((key) => SECRET_KEY_LABELS[key as string]); @@ -122,8 +120,7 @@ export function serializeSettings( for (const key of Object.keys(settings)) { if (DANGEROUS_KEYS.has(key)) continue; if (!IMPORTABLE_KEYS.has(key)) continue; - if (SECRET_KEYS.has(key as keyof IPodNotesSettings) && !opts.includeSecret) - continue; + if (SECRET_KEYS.has(key as keyof IPodNotesSettings) && !opts.includeSecret) continue; out[key] = settings[key as keyof IPodNotesSettings]; } @@ -161,11 +158,7 @@ export function parseImport(jsonText: string): ParseResult { if (raw.type === SETTINGS_EXPORT_TYPE) { fromEnvelope = true; - if ( - typeof raw.version !== "number" || - !Number.isInteger(raw.version) || - raw.version < 1 - ) { + if (typeof raw.version !== "number" || !Number.isInteger(raw.version) || raw.version < 1) { return { ok: false, error: "Export file has an invalid version." }; } if (raw.version > SETTINGS_EXPORT_VERSION) { @@ -241,9 +234,7 @@ export function mergeImportedSettings( return merged; } -function sanitizeImportedSettings( - source: Record<string, unknown>, -): Partial<IPodNotesSettings> { +function sanitizeImportedSettings(source: Record<string, unknown>): Partial<IPodNotesSettings> { const out: Record<string, unknown> = {}; for (const [key, value] of Object.entries(source)) { @@ -261,10 +252,7 @@ function sanitizeImportedSettings( // must not clobber a key the user already has. Skipping it here keeps it // absent from `imported`, so the merge `{ ...current, ...imported }` // preserves the configured key and meta.includesSecret stays honest. - if ( - SECRET_KEYS.has(key as keyof IPodNotesSettings) && - (value as string).trim() === "" - ) { + if (SECRET_KEYS.has(key as keyof IPodNotesSettings) && (value as string).trim() === "") { continue; } @@ -294,9 +282,7 @@ function sanitizeImportedSettings( } /** Drop any playlist entry that isn't a plain object with an `episodes` array. */ -function sanitizePlaylistMap( - value: Record<string, unknown>, -): Record<string, unknown> { +function sanitizePlaylistMap(value: Record<string, unknown>): Record<string, unknown> { const out: Record<string, unknown> = {}; for (const [name, playlist] of Object.entries(value)) { diff --git a/src/store/downloads.test.ts b/src/store/downloads.test.ts index 079b3b1c..62b4cc52 100644 --- a/src/store/downloads.test.ts +++ b/src/store/downloads.test.ts @@ -30,11 +30,7 @@ describe("downloadedEpisodes.addEpisode basename collision (#LF-06)", () => { // Same podcastName + title, DIFFERENT path -> basename collision. The store // stays pure (no Notice); it signals the collision to the caller, which warns. - const replaced = downloadedEpisodes.addEpisode( - makeEpisode(), - "Folder B/Recording.mp3", - 20, - ); + const replaced = downloadedEpisodes.addEpisode(makeEpisode(), "Folder B/Recording.mp3", 20); expect(replaced).toBe("Folder A/Recording.mp3"); // Behavior is otherwise unchanged: the single entry is replaced in place, diff --git a/src/store/downloads.ts b/src/store/downloads.ts index 2bea6fd7..3d4626b6 100644 --- a/src/store/downloads.ts +++ b/src/store/downloads.ts @@ -12,9 +12,7 @@ export const downloadedEpisodes = (() => { const { subscribe, update, set } = store; function isEpisodeDownloaded(episode: Episode): boolean { - return get(store)[episode.podcastName]?.some( - (e) => e.title === episode.title, - ); + return get(store)[episode.podcastName]?.some((e) => e.title === episode.title); } return { @@ -28,46 +26,36 @@ export const downloadedEpisodes = (() => { * collision), or `undefined` otherwise. The caller surfaces a Notice — this * store stays pure (no UI/side effects), per the PR #211/#212 layering (#LF-06). */ - addEpisode: ( - episode: Episode, - filePath: string, - size: number, - ): string | undefined => { + addEpisode: (episode: Episode, filePath: string, size: number): string | undefined => { let replacedFilePath: string | undefined; - update( - (downloadedEpisodes: { - [podcastName: string]: DownloadedEpisode[]; - }) => { - const podcastEpisodes = downloadedEpisodes[episode.podcastName] || []; + update((downloadedEpisodes: { [podcastName: string]: DownloadedEpisode[] }) => { + const podcastEpisodes = downloadedEpisodes[episode.podcastName] || []; - const idx = podcastEpisodes.findIndex( - (ep) => ep.title === episode.title, - ); - if (idx !== -1) { - // Entries are keyed by podcastName+title, so two distinct local - // files that share a basename in different folders collapse onto - // the same key and the second replaces the first. The key must stay - // basename-only (URIHandler/{{episodelink}} resolve local files by - // basename), so report the collision to the caller instead of - // overwriting silently (#LF-06). - const existingFilePath = podcastEpisodes[idx].filePath; - if (existingFilePath && existingFilePath !== filePath) { - replacedFilePath = existingFilePath; - } - podcastEpisodes[idx] = { ...episode, filePath, size }; - } else { - podcastEpisodes.push({ - ...episode, - filePath, - size, - }); + const idx = podcastEpisodes.findIndex((ep) => ep.title === episode.title); + if (idx !== -1) { + // Entries are keyed by podcastName+title, so two distinct local + // files that share a basename in different folders collapse onto + // the same key and the second replaces the first. The key must stay + // basename-only (URIHandler/{{episodelink}} resolve local files by + // basename), so report the collision to the caller instead of + // overwriting silently (#LF-06). + const existingFilePath = podcastEpisodes[idx].filePath; + if (existingFilePath && existingFilePath !== filePath) { + replacedFilePath = existingFilePath; } + podcastEpisodes[idx] = { ...episode, filePath, size }; + } else { + podcastEpisodes.push({ + ...episode, + filePath, + size, + }); + } - downloadedEpisodes[episode.podcastName] = podcastEpisodes; - return downloadedEpisodes; - }, - ); + downloadedEpisodes[episode.podcastName] = podcastEpisodes; + return downloadedEpisodes; + }); return replacedFilePath; }, @@ -81,9 +69,7 @@ export const downloadedEpisodes = (() => { update((downloadedEpisodes) => { const podcastEpisodes = downloadedEpisodes[episode.podcastName] || []; - const index = podcastEpisodes.findIndex( - (e) => e.title === episode.title, - ); + const index = podcastEpisodes.findIndex((e) => e.title === episode.title); // Guard against episode not found if (index === -1) { @@ -100,9 +86,7 @@ export const downloadedEpisodes = (() => { return removedFilePath; }, getEpisode: (episode: Episode) => { - return get(store)[episode.podcastName]?.find( - (e) => e.title === episode.title, - ); + return get(store)[episode.podcastName]?.find((e) => e.title === episode.title); }, }; })(); diff --git a/src/store/feeds.ts b/src/store/feeds.ts index a6a3a7b8..887487ce 100644 --- a/src/store/feeds.ts +++ b/src/store/feeds.ts @@ -1,10 +1,7 @@ import { get, readable, writable } from "svelte/store"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; -import { - DEFAULT_EPISODE_LIST_LIMIT, - MAX_EPISODE_LIST_LIMIT, -} from "src/constants"; +import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; /** * Saved-feed metadata, the per-feed episode cache, and the aggregated "Latest @@ -53,10 +50,7 @@ function getEpisodeTimestamp(episode?: Episode): number { return Number.isFinite(timestamp) ? timestamp : 0; } -function getLatestEpisodesForFeed( - episodes: Episode[], - perFeedLimit: number, -): Episode[] { +function getLatestEpisodesForFeed(episodes: Episode[], perFeedLimit: number): Episode[] { if (!episodes?.length) return []; // Sort by date first, THEN take the newest N. Slicing before sorting would @@ -121,9 +115,7 @@ function removeFeedEntries( const feedKeys = new Set(feedEpisodes.map(latestEpisodeIdentifier)); - return currentLatest.filter( - (episode) => !feedKeys.has(latestEpisodeIdentifier(episode)), - ); + return currentLatest.filter((episode) => !feedKeys.has(latestEpisodeIdentifier(episode))); } function updateLatestEpisodesForFeed( @@ -189,10 +181,7 @@ export const latestEpisodes = readable<Episode[]>([], (set) => { for (const feedTitle of latestByFeed.keys()) { if (!nextSources.has(feedTitle)) { changed = true; - nextMerged = removeFeedEntries( - nextMerged, - latestByFeed.get(feedTitle), - ); + nextMerged = removeFeedEntries(nextMerged, latestByFeed.get(feedTitle)); } } @@ -219,10 +208,7 @@ export const latestEpisodes = readable<Episode[]>([], (set) => { const collected: Episode[] = []; for (const [feedTitle, episodes] of cacheEntries) { - const nextLatestForFeed = getLatestEpisodesForFeed( - episodes, - perFeedLimit, - ); + const nextLatestForFeed = getLatestEpisodesForFeed(episodes, perFeedLimit); nextSources.set(feedTitle, episodes); nextLatestByFeed.set(feedTitle, nextLatestForFeed); collected.push(...nextLatestForFeed); diff --git a/src/store/index.test.ts b/src/store/index.test.ts index 45e1c7b3..205d925d 100644 --- a/src/store/index.test.ts +++ b/src/store/index.test.ts @@ -140,10 +140,7 @@ describe("localFiles store — syncWithDownloaded (issue #176)", () => { const episodes = get(localFiles).episodes; expect(episodes).toHaveLength(2); - expect(episodes.map((ep) => ep.podcastName).sort()).toEqual([ - "Podcast A", - "Podcast B", - ]); + expect(episodes.map((ep) => ep.podcastName).sort()).toEqual(["Podcast A", "Podcast B"]); }); test("preserves filePath/size and the real podcastName (not coerced)", () => { @@ -271,9 +268,7 @@ describe("localFiles store — syncWithDownloaded (issue #176)", () => { ], }); - expect(localFiles.getLocalEpisode("Collision")?.podcastName).toBe( - "local file", - ); + expect(localFiles.getLocalEpisode("Collision")?.podcastName).toBe("local file"); }); test("dedupes identical composite keys, keeping the first occurrence", () => { @@ -317,9 +312,7 @@ describe("localFiles store — syncWithDownloaded (issue #176)", () => { const [ep] = get(localFiles).episodes; expect(ep?.title).toBe("My Recording"); expect(ep?.podcastName).toBe("local file"); - expect((ep as DownloadedEpisode).filePath).toBe( - "recordings/my recording.m4a", - ); + expect((ep as DownloadedEpisode).filePath).toBe("recordings/my recording.m4a"); }); }); @@ -380,21 +373,11 @@ describe("reorderEpisodes", () => { const titles = (list: Episode[]) => list.map((e) => e.title); test("moves forward (from < to)", () => { - expect(titles(reorderEpisodes(episodes, 0, 2))).toEqual([ - "B", - "C", - "A", - "D", - ]); + expect(titles(reorderEpisodes(episodes, 0, 2))).toEqual(["B", "C", "A", "D"]); }); test("moves backward (from > to)", () => { - expect(titles(reorderEpisodes(episodes, 3, 1))).toEqual([ - "A", - "D", - "B", - "C", - ]); + expect(titles(reorderEpisodes(episodes, 3, 1))).toEqual(["A", "D", "B", "C"]); }); test("move to last index truly lands last", () => { @@ -422,12 +405,7 @@ describe("reorderEpisodes", () => { describe("dedupeEpisodesByTitle", () => { test("keeps the first occurrence and preserves order", () => { - const result = dedupeEpisodesByTitle([ - ep("A"), - ep("B"), - ep("A"), - ep("C"), - ]); + const result = dedupeEpisodesByTitle([ep("A"), ep("B"), ep("A"), ep("C")]); expect(result.map((e) => e.title)).toEqual(["A", "B", "C"]); }); @@ -504,9 +482,7 @@ describe("queue store", () => { try { queue.moveToBottom(0); - expect( - fakePlugin.settings.queue.episodes.map((e) => e.title), - ).toEqual(["B", "C", "A"]); + expect(fakePlugin.settings.queue.episodes.map((e) => e.title)).toEqual(["B", "C", "A"]); expect(fakePlugin.saveSettings).toHaveBeenCalled(); } finally { unbind(); @@ -604,22 +580,14 @@ describe("sanitizeEpisodeListLimit (issue #114)", () => { test("falls back to the default for missing/invalid/non-positive values", () => { expect(sanitizeEpisodeListLimit(0)).toBe(DEFAULT_EPISODE_LIST_LIMIT); expect(sanitizeEpisodeListLimit(-5)).toBe(DEFAULT_EPISODE_LIST_LIMIT); - expect(sanitizeEpisodeListLimit(Number.NaN)).toBe( - DEFAULT_EPISODE_LIST_LIMIT, - ); - expect(sanitizeEpisodeListLimit(undefined)).toBe( - DEFAULT_EPISODE_LIST_LIMIT, - ); + expect(sanitizeEpisodeListLimit(Number.NaN)).toBe(DEFAULT_EPISODE_LIST_LIMIT); + expect(sanitizeEpisodeListLimit(undefined)).toBe(DEFAULT_EPISODE_LIST_LIMIT); expect(sanitizeEpisodeListLimit("nope")).toBe(DEFAULT_EPISODE_LIST_LIMIT); }); test("clamps to the maximum", () => { - expect(sanitizeEpisodeListLimit(MAX_EPISODE_LIST_LIMIT)).toBe( - MAX_EPISODE_LIST_LIMIT, - ); - expect(sanitizeEpisodeListLimit(MAX_EPISODE_LIST_LIMIT + 1)).toBe( - MAX_EPISODE_LIST_LIMIT, - ); + expect(sanitizeEpisodeListLimit(MAX_EPISODE_LIST_LIMIT)).toBe(MAX_EPISODE_LIST_LIMIT); + expect(sanitizeEpisodeListLimit(MAX_EPISODE_LIST_LIMIT + 1)).toBe(MAX_EPISODE_LIST_LIMIT); expect(sanitizeEpisodeListLimit(10 ** 9)).toBe(MAX_EPISODE_LIST_LIMIT); }); }); diff --git a/src/store/index.ts b/src/store/index.ts index 287a8011..319d0c90 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -10,10 +10,7 @@ import type { PlaybackSegment } from "src/types/PlaybackSegment"; import { LOCAL_FILES_SETTINGS } from "src/constants"; import { DEFAULT_PLAYBACK_RATE } from "src/utility/playbackRate"; import { getEpisodeKey } from "src/utility/episodeKey"; -import { - getPlayedEpisode, - getPlayedEpisodeAliasKeys, -} from "src/utility/episodeStatus"; +import { getPlayedEpisode, getPlayedEpisodeAliasKeys } from "src/utility/episodeStatus"; // `src/store` is the single import surface for the store layer. The feed/cache/ // Latest-Episodes projection and the offline downloads store are self-contained @@ -169,11 +166,7 @@ function markPlayedEpisodeAliasesAsUnplayed( episode: Pick<PlayedEpisode, "title" | "podcastName">, preferredKey: string, ) { - const aliasKeys = getPlayedEpisodeAliasKeys( - playedEpisodeMap, - episode, - preferredKey, - ); + const aliasKeys = getPlayedEpisodeAliasKeys(playedEpisodeMap, episode, preferredKey); const keysToUpdate = aliasKeys.length > 0 ? aliasKeys : [preferredKey]; for (const key of keysToUpdate) { @@ -202,11 +195,7 @@ function markPlayedEpisodeAliasesAsUnplayed( * `findIndex` miss yields -1, and `splice(-1, 1)` would destructively remove the * last element. */ -export function reorderEpisodes( - episodes: Episode[], - from: number, - to: number, -): Episode[] { +export function reorderEpisodes(episodes: Episode[], from: number, to: number): Episode[] { const length = episodes.length; if (from < 0 || from >= length || to < 0 || to >= length || from === to) { return episodes; @@ -287,9 +276,7 @@ export const queue = (() => { }, remove: (episode: Episode) => { update((queue) => { - queue.episodes = queue.episodes.filter( - (e) => e.title !== episode.title, - ); + queue.episodes = queue.episodes.filter((e) => e.title !== episode.title); return queue; }); }, @@ -313,8 +300,7 @@ export const queue = (() => { moveUp: (index: number) => move(index, index - 1), moveDown: (index: number) => move(index, index + 1), moveToTop: (index: number) => move(index, 0), - moveToBottom: (index: number) => - move(index, get(store).episodes.length - 1), + moveToBottom: (index: number) => move(index, get(store).episodes.length - 1), }; })(); @@ -333,9 +319,7 @@ function getEpisodeFilePath(episode: Episode): string | undefined { function sameEpisodeProjection(a: Episode[], b: Episode[]): boolean { if (a.length !== b.length) return false; - const episodesInA = new Map( - a.map((episode) => [getEpisodeKey(episode), episode]), - ); + const episodesInA = new Map(a.map((episode) => [getEpisodeKey(episode), episode])); for (const episode of b) { const key = getEpisodeKey(episode); const existing = key ? episodesInA.get(key) : undefined; @@ -380,9 +364,7 @@ export const localFiles = (() => { * dual-write in getContextMenuHandler, so nothing reachable is lost — and a * removed download now correctly disappears from Local Files too. */ - syncWithDownloaded: (downloaded: { - [podcastName: string]: DownloadedEpisode[]; - }): void => { + syncWithDownloaded: (downloaded: { [podcastName: string]: DownloadedEpisode[] }): void => { const seen = new Set<string>(); const episodes: Episode[] = []; @@ -413,8 +395,7 @@ export const localFiles = (() => { // manual local file so a same-titled downloaded episode (now mirrored // into this playlist) can't shadow it. const target = title.trim().toLowerCase(); - const matches = (episode: Episode) => - episode.title.trim().toLowerCase() === target; + const matches = (episode: Episode) => episode.title.trim().toLowerCase() === target; const ep = episodes.find( (episode) => matches(episode) && episode.podcastName === "local file", @@ -484,9 +465,7 @@ export function subscribeQueueToCurrentEpisode(): Unsubscriber { if (!episodeIsInQueue) return; queue.update((playlist) => { - playlist.episodes = playlist.episodes.filter( - (e) => e.title !== episode.title, - ); + playlist.episodes = playlist.episodes.filter((e) => e.title !== episode.title); return playlist; }); }); diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 0ac186e9..befa4214 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -1,9 +1,5 @@ import type { Readable, Unsubscriber } from "svelte/store"; -import { - FAVORITES_SETTINGS, - LOCAL_FILES_SETTINGS, - QUEUE_SETTINGS, -} from "src/constants"; +import { FAVORITES_SETTINGS, LOCAL_FILES_SETTINGS, QUEUE_SETTINGS } from "src/constants"; import type { IPodNotes } from "src/types/IPodNotes"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { @@ -103,10 +99,7 @@ const BINDINGS: PersistenceBinding[] = [ export function bindStoresToSettings(plugin: IPodNotes): Unsubscriber { const unsubscribers = BINDINGS.map((binding) => binding.subscribe((value) => { - if ( - binding.shouldPersist && - !binding.shouldPersist(plugin.settings, value) - ) { + if (binding.shouldPersist && !binding.shouldPersist(plugin.settings, value)) { return; } diff --git a/src/types/CSSObject.ts b/src/types/CSSObject.ts index 4e941a38..fb0d7b35 100644 --- a/src/types/CSSObject.ts +++ b/src/types/CSSObject.ts @@ -1,3 +1,3 @@ export type CSSObject = { - [key: string]: CSSStyleDeclaration[keyof CSSStyleDeclaration]; + [key: string]: CSSStyleDeclaration[keyof CSSStyleDeclaration]; }; diff --git a/src/types/Chapter.ts b/src/types/Chapter.ts index 4d6455b9..bf3d8b6c 100644 --- a/src/types/Chapter.ts +++ b/src/types/Chapter.ts @@ -4,21 +4,21 @@ * @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md */ export interface Chapter { - /** Start time in seconds */ - startTime: number; - /** Optional end time in seconds */ - endTime?: number; - /** Chapter title */ - title: string; - /** Optional chapter artwork URL */ - img?: string; - /** Optional link URL */ - url?: string; - /** Whether this chapter should be hidden (ad, etc.) */ - toc?: boolean; + /** Start time in seconds */ + startTime: number; + /** Optional end time in seconds */ + endTime?: number; + /** Chapter title */ + title: string; + /** Optional chapter artwork URL */ + img?: string; + /** Optional link URL */ + url?: string; + /** Whether this chapter should be hidden (ad, etc.) */ + toc?: boolean; } export interface ChaptersData { - version: string; - chapters: Chapter[]; + version: string; + chapters: Chapter[]; } diff --git a/src/types/Episode.ts b/src/types/Episode.ts index 70b7576e..92053ae6 100644 --- a/src/types/Episode.ts +++ b/src/types/Episode.ts @@ -1,13 +1,13 @@ export type EpisodeMediaType = "audio" | "video"; export interface Episode { - title: string, - streamUrl: string - url: string, - description: string, - content: string, - podcastName: string, - feedUrl?: string, + title: string; + streamUrl: string; + url: string; + description: string; + content: string; + podcastName: string; + feedUrl?: string; artworkUrl?: string; episodeDate?: Date; itunesTitle?: string; diff --git a/src/types/LocalEpisode.ts b/src/types/LocalEpisode.ts index d7eec8c1..c741c6b2 100644 --- a/src/types/LocalEpisode.ts +++ b/src/types/LocalEpisode.ts @@ -1,8 +1,8 @@ import type { Episode } from "./Episode"; export interface LocalEpisode extends Episode { - podcastName: "local file", - description: "", - content: "", - filePath?: string, + podcastName: "local file"; + description: ""; + content: ""; + filePath?: string; } diff --git a/src/types/PlayedEpisode.ts b/src/types/PlayedEpisode.ts index 1766544b..fd484113 100644 --- a/src/types/PlayedEpisode.ts +++ b/src/types/PlayedEpisode.ts @@ -1,7 +1,7 @@ export interface PlayedEpisode { - title: string; - podcastName: string; - time: number; - duration: number; - finished: boolean; -} \ No newline at end of file + title: string; + podcastName: string; + time: number; + duration: number; + finished: boolean; +} diff --git a/src/types/Playlist.ts b/src/types/Playlist.ts index f84d81d9..58d376d2 100644 --- a/src/types/Playlist.ts +++ b/src/types/Playlist.ts @@ -2,13 +2,13 @@ import type { Episode } from "./Episode"; import type { IconType } from "./IconType"; export type Playlist = { - icon: IconType, + icon: IconType; name: string; episodes: Episode[]; - + currentEpisode?: Episode; shouldEpisodeRemoveAfterPlay: boolean; shouldRepeat: boolean; isVirtual?: boolean; -} +}; diff --git a/src/types/PodNotes.ts b/src/types/PodNotes.ts index 1ebcf600..99c9e2ce 100644 --- a/src/types/PodNotes.ts +++ b/src/types/PodNotes.ts @@ -2,4 +2,4 @@ export type PodNote = { episodeName: string; filePath: string; podcastFeedKey: string; -} +}; diff --git a/src/types/ViewState.ts b/src/types/ViewState.ts index 20103f93..4b3fe4cd 100644 --- a/src/types/ViewState.ts +++ b/src/types/ViewState.ts @@ -1,5 +1,5 @@ export enum ViewState { - PodcastGrid, - EpisodeList, - Player, -} \ No newline at end of file + PodcastGrid, + EpisodeList, + Player, +} diff --git a/src/ui/FeedSuggestModal.test.ts b/src/ui/FeedSuggestModal.test.ts index a1c873bb..93582a06 100644 --- a/src/ui/FeedSuggestModal.test.ts +++ b/src/ui/FeedSuggestModal.test.ts @@ -16,18 +16,11 @@ describe("orderFeedsByCurrent", () => { it("moves the current podcast's feed to the front", () => { const feeds = [feed("A"), feed("B"), feed("C")]; - expect(orderFeedsByCurrent(feeds, "B").map((f) => f.title)).toEqual([ - "B", - "A", - "C", - ]); + expect(orderFeedsByCurrent(feeds, "B").map((f) => f.title)).toEqual(["B", "A", "C"]); }); it("is a no-op when the current podcast has no saved feed", () => { const feeds = [feed("A"), feed("B")]; - expect(orderFeedsByCurrent(feeds, "Z").map((f) => f.title)).toEqual([ - "A", - "B", - ]); + expect(orderFeedsByCurrent(feeds, "Z").map((f) => f.title)).toEqual(["A", "B"]); }); }); diff --git a/src/ui/FeedSuggestModal.ts b/src/ui/FeedSuggestModal.ts index 9f3d009f..4ffc37be 100644 --- a/src/ui/FeedSuggestModal.ts +++ b/src/ui/FeedSuggestModal.ts @@ -26,11 +26,7 @@ export class FeedSuggestModal extends FuzzySuggestModal<PodcastFeed> { private feeds: PodcastFeed[]; private onChoose: (feed: PodcastFeed) => void; - constructor( - app: App, - feeds: PodcastFeed[], - onChoose: (feed: PodcastFeed) => void, - ) { + constructor(app: App, feeds: PodcastFeed[], onChoose: (feed: PodcastFeed) => void) { super(app); this.feeds = feeds; this.onChoose = onChoose; diff --git a/src/ui/PodcastView/ChapterList.test.ts b/src/ui/PodcastView/ChapterList.test.ts index 45c9c250..ad813ea0 100644 --- a/src/ui/PodcastView/ChapterList.test.ts +++ b/src/ui/PodcastView/ChapterList.test.ts @@ -73,21 +73,15 @@ describe("ChapterList", () => { props: { chapters, currentTime: 0 }, }); - const header = container.querySelector( - ".chapter-header", - ) as HTMLButtonElement; + const header = container.querySelector(".chapter-header") as HTMLButtonElement; const list = container.querySelector(".chapters") as HTMLUListElement; expect(list.id).toBeTruthy(); expect(header.getAttribute("aria-controls")).toBe(list.id); - const labels = Array.from(container.querySelectorAll(".chapter-item")).map( - (b) => b.getAttribute("aria-label"), + const labels = Array.from(container.querySelectorAll(".chapter-item")).map((b) => + b.getAttribute("aria-label"), ); - expect(labels).toEqual([ - "Jump to Intro", - "Jump to Main topic", - "Jump to Wrap up", - ]); + expect(labels).toEqual(["Jump to Intro", "Jump to Main topic", "Jump to Wrap up"]); }); }); diff --git a/src/ui/PodcastView/EpisodePlayer.test.ts b/src/ui/PodcastView/EpisodePlayer.test.ts index 47e337d3..2046395d 100644 --- a/src/ui/PodcastView/EpisodePlayer.test.ts +++ b/src/ui/PodcastView/EpisodePlayer.test.ts @@ -80,9 +80,7 @@ function makeTFile(path: string): TFile { function mockVaultFile(path: string): void { const file = makeTFile(path); const vault = { - getAbstractFileByPath: vi.fn((candidate: string) => - candidate === path ? file : null, - ), + getAbstractFileByPath: vi.fn((candidate: string) => (candidate === path ? file : null)), getResourcePath: vi.fn(() => `app://resource/${path}?token`), }; // The player reads the vault off the plugin's app reference ($plugin.app), @@ -349,9 +347,7 @@ describe("EpisodePlayer", () => { await waitFor(() => { expect(audio.playbackRate).toBe(1.7); }); - expect(container.querySelector(".playbackrate-container span")?.textContent).toBe( - "1.7x", - ); + expect(container.querySelector(".playbackrate-container span")?.textContent).toBe("1.7x"); }); test("updates playbackRate store from the playback-rate slider", async () => { @@ -458,9 +454,7 @@ describe("EpisodePlayer", () => { const audio = container.querySelector("audio") as HTMLAudioElement; expect(container.querySelector("video")).toBeNull(); - expect(audio.getAttribute("src")).toBe( - "app://resource/Downloads/legacy-audio.mp4?token", - ); + expect(audio.getAttribute("src")).toBe("app://resource/Downloads/legacy-audio.mp4?token"); }); test("uses a legacy downloaded video mp4 file for an explicitly video feed episode", async () => { @@ -491,9 +485,7 @@ describe("EpisodePlayer", () => { const video = container.querySelector("video") as HTMLVideoElement; expect(container.querySelector("audio")).toBeNull(); - expect(video.getAttribute("src")).toBe( - "app://resource/Downloads/legacy-video.mp4?token", - ); + expect(video.getAttribute("src")).toBe("app://resource/Downloads/legacy-video.mp4?token"); }); test("uses matching downloaded video metadata to classify extensionless records", async () => { @@ -633,9 +625,7 @@ describe("EpisodePlayer — persists playback position during playback (issue #3 await waitFor(() => { expect(container.querySelector("audio")).not.toBeNull(); }); - await fireEvent.loadedMetadata( - container.querySelector("audio") as HTMLAudioElement, - ); + await fireEvent.loadedMetadata(container.querySelector("audio") as HTMLAudioElement); // Switch to B in-player (queue click / auto-advance reuse this instance). // The re-arm must set isLoading=true until B's metadata loads. diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 74928195..9297a57e 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -1,19 +1,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { get } from "svelte/store"; -import { - afterEach, - beforeEach, - describe, - expect, - test, - vi, -} from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import createPodcastNote from "src/createPodcastNote"; -import { - clearFeedCache, - setCachedEpisodes, -} from "src/services/FeedCacheService"; +import { clearFeedCache, setCachedEpisodes } from "src/services/FeedCacheService"; import { currentEpisode, episodeCache, @@ -90,9 +80,7 @@ function createNumberedEpisode(number: number): Episode { } function createTruncatedFeedCache(): Episode[] { - return Array.from({ length: 75 }, (_, index) => - createNumberedEpisode(622 + index), - ); + return Array.from({ length: 75 }, (_, index) => createNumberedEpisode(622 + index)); } function createFullFeed(): Episode[] { @@ -181,16 +169,13 @@ describe("PodcastView integration flow", () => { expect(current).toBeDefined(); await createPodcastNote(current as Episode); - const expectedPath = - "Podcasts/Test Podcast/2024-01-15/Episode 1 Launch.md"; + const expectedPath = "Podcasts/Test Podcast/2024-01-15/Episode 1 Launch.md"; expect(createdFiles[0].path).toBe(expectedPath); expect(createdFiles[0].data).toBe( "# Episode 1: Launch\nEpisode description\nStream: https://pod.example.com/audio.mp3", ); - expect(leaf.openFile).toHaveBeenCalledWith( - expect.objectContaining({ path: expectedPath }), - ); + expect(leaf.openFile).toHaveBeenCalledWith(expect.objectContaining({ path: expectedPath })); }); test("shows loading state while fetching and streams episodes per feed", async () => { @@ -260,21 +245,15 @@ describe("PodcastView integration flow", () => { resolveFirstFeed([firstEpisode]); - expect( - await screen.findByText(firstEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(firstEpisode.title)).toBeInTheDocument(); expect(screen.getByText("Fetching episodes...")).toBeInTheDocument(); expect(screen.queryByText(secondEpisode.title)).toBeNull(); resolveSecondFeed([secondEpisode]); - expect( - await screen.findByText(secondEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(secondEpisode.title)).toBeInTheDocument(); await waitFor(() => - expect( - screen.queryByText("Fetching episodes..."), - ).not.toBeInTheDocument(), + expect(screen.queryByText("Fetching episodes...")).not.toBeInTheDocument(), ); }); @@ -324,14 +303,12 @@ describe("PodcastView integration flow", () => { }; let resolvePlayedFetch!: (value: Episode[]) => void; - mockGetEpisodes - .mockResolvedValueOnce([testEpisode]) - .mockImplementationOnce( - () => - new Promise<Episode[]>((resolve) => { - resolvePlayedFetch = resolve; - }), - ); + mockGetEpisodes.mockResolvedValueOnce([testEpisode]).mockImplementationOnce( + () => + new Promise<Episode[]>((resolve) => { + resolvePlayedFetch = resolve; + }), + ); playedEpisodes.set({ [`${testFeed.title}::${playedEpisode.title}`]: { title: playedEpisode.title, @@ -359,9 +336,7 @@ describe("PodcastView integration flow", () => { await fireEvent.click(playedCard); expect(await screen.findByText("Played")).toBeInTheDocument(); - await fireEvent.click( - screen.getByRole("button", { name: /latest episodes/i }), - ); + await fireEvent.click(screen.getByRole("button", { name: /latest episodes/i })); expect(screen.queryByText("Already Finished")).not.toBeInTheDocument(); resolvePlayedFetch([testEpisode]); @@ -386,22 +361,16 @@ describe("issue #174 feed cache cap regression", () => { test("opening a show bypasses the truncated cache and loads older episodes", async () => { render(PodcastView); - await waitFor(() => - expect(get(episodeCache)[testFeed.title]).toHaveLength(75), - ); + await waitFor(() => expect(get(episodeCache)[testFeed.title]).toHaveLength(75)); expect(mockGetEpisodes).not.toHaveBeenCalled(); const feedImage = await screen.findByAltText(testFeed.title); await fireEvent.click(feedImage); await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); expect(get(episodeCache)[testFeed.title]).toHaveLength(76); - expect( - screen.queryByText(createNumberedEpisode(621).title), - ).not.toBeInTheDocument(); + expect(screen.queryByText(createNumberedEpisode(621).title)).not.toBeInTheDocument(); }); test("played view bypasses the truncated cache for older finished episodes", async () => { @@ -418,9 +387,7 @@ describe("issue #174 feed cache cap regression", () => { render(PodcastView); - await waitFor(() => - expect(get(episodeCache)[testFeed.title]).toHaveLength(75), - ); + await waitFor(() => expect(get(episodeCache)[testFeed.title]).toHaveLength(75)); expect(mockGetEpisodes).not.toHaveBeenCalled(); const playedCard = await screen.findByLabelText("Played"); @@ -428,12 +395,8 @@ describe("issue #174 feed cache cap regression", () => { await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); expect(await screen.findByText("Played")).toBeInTheDocument(); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); - expect( - screen.queryByText("Unavailable in current feeds"), - ).not.toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); + expect(screen.queryByText("Unavailable in current feeds")).not.toBeInTheDocument(); await fireEvent.click(screen.getByText(oldEpisode.title)); expect(get(currentEpisode)).toMatchObject({ title: oldEpisode.title }); @@ -445,16 +408,10 @@ describe("issue #174 feed cache cap regression", () => { render(PodcastView); - await waitFor(() => - expect(get(episodeCache)[testFeed.title]).toHaveLength(75), - ); + await waitFor(() => expect(get(episodeCache)[testFeed.title]).toHaveLength(75)); expect(mockGetEpisodes).not.toHaveBeenCalled(); - expect( - screen.queryByText(oldEpisode.title), - ).not.toBeInTheDocument(); - expect( - await screen.findByText(createNumberedEpisode(622).title), - ).toBeInTheDocument(); + expect(screen.queryByText(oldEpisode.title)).not.toBeInTheDocument(); + expect(await screen.findByText(createNumberedEpisode(622).title)).toBeInTheDocument(); }); test("reopening a show reuses full in-memory cache without refetching", async () => { @@ -464,19 +421,13 @@ describe("issue #174 feed cache cap regression", () => { await fireEvent.click(feedImage); await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); - await fireEvent.click( - screen.getByRole("button", { name: /podcast grid/i }), - ); + await fireEvent.click(screen.getByRole("button", { name: /podcast grid/i })); expect(get(viewState)).toBe(ViewState.PodcastGrid); await fireEvent.click(feedImage); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); expect(mockGetEpisodes).toHaveBeenCalledTimes(1); }); @@ -488,9 +439,7 @@ describe("issue #174 feed cache cap regression", () => { const feedImage = await screen.findByAltText(testFeed.title); await fireEvent.click(feedImage); - expect( - await screen.findByText(createNumberedEpisode(622).title), - ).toBeInTheDocument(); + expect(await screen.findByText(createNumberedEpisode(622).title)).toBeInTheDocument(); expect(screen.queryByText(oldEpisode.title)).not.toBeInTheDocument(); expect(mockGetEpisodes).toHaveBeenCalledTimes(1); }); @@ -528,9 +477,7 @@ describe("issue #174 feed cache cap regression", () => { await fireEvent.click(playedCard); await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); }); test("reopening played view reuses full in-memory cache without refetching", async () => { @@ -551,18 +498,12 @@ describe("issue #174 feed cache cap regression", () => { await fireEvent.click(playedCard); await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); - await fireEvent.click( - screen.getByRole("button", { name: /latest episodes/i }), - ); + await fireEvent.click(screen.getByRole("button", { name: /latest episodes/i })); await fireEvent.click(playedCard); - expect( - await screen.findByText(oldEpisode.title), - ).toBeInTheDocument(); + expect(await screen.findByText(oldEpisode.title)).toBeInTheDocument(); expect(mockGetEpisodes).toHaveBeenCalledTimes(1); }); }); diff --git a/src/ui/PodcastView/TopBar.test.ts b/src/ui/PodcastView/TopBar.test.ts index 07a5b8ca..73008753 100644 --- a/src/ui/PodcastView/TopBar.test.ts +++ b/src/ui/PodcastView/TopBar.test.ts @@ -24,9 +24,7 @@ describe("TopBar", () => { expect(player).toBeDisabled(); expect(player.className).not.toContain("topbar-selectable"); expect(player.className).toContain("topbar-disabled"); - expect(player.getAttribute("title")).toBe( - "Start playing an episode to open the player." - ); + expect(player.getAttribute("title")).toBe("Start playing an episode to open the player."); expect(episode.getAttribute("title")).toBe("View episode list"); }); @@ -66,10 +64,10 @@ describe("TopBar", () => { expect(episodeButton.className).toContain("topbar-disabled"); expect(playerButton.className).toContain("topbar-disabled"); expect(episodeButton.getAttribute("title")).toBe( - "Select a podcast or playlist to view its episodes." + "Select a podcast or playlist to view its episodes.", ); expect(playerButton.getAttribute("title")).toBe( - "Start playing an episode to open the player." + "Start playing an episode to open the player.", ); }); diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index efcc8847..50bb92c3 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -6,7 +6,10 @@ import PodcastView from "./PodcastView.svelte"; import { mount, unmount } from "svelte"; export class MainView extends ItemView { - constructor(leaf: WorkspaceLeaf, private plugin: IPodNotes) { + constructor( + leaf: WorkspaceLeaf, + private plugin: IPodNotes, + ) { super(leaf); } diff --git a/src/ui/PodcastView/queueReorderMenu.test.ts b/src/ui/PodcastView/queueReorderMenu.test.ts index 6d070a3a..648ec89d 100644 --- a/src/ui/PodcastView/queueReorderMenu.test.ts +++ b/src/ui/PodcastView/queueReorderMenu.test.ts @@ -20,32 +20,24 @@ const titles = (items: { title: string }[]) => items.map((i) => i.title); describe("buildQueueReorderMenuItems", () => { test("returns nothing outside the Player view", () => { - expect( - buildQueueReorderMenuItems(ViewState.EpisodeList, queueEpisodes, ep("B")), - ).toEqual([]); - expect( - buildQueueReorderMenuItems(ViewState.PodcastGrid, queueEpisodes, ep("B")), - ).toEqual([]); + expect(buildQueueReorderMenuItems(ViewState.EpisodeList, queueEpisodes, ep("B"))).toEqual( + [], + ); + expect(buildQueueReorderMenuItems(ViewState.PodcastGrid, queueEpisodes, ep("B"))).toEqual( + [], + ); }); test("returns nothing when the episode is not queued", () => { - expect( - buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("Z")), - ).toEqual([]); + expect(buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("Z"))).toEqual([]); }); test("returns nothing for a single-item queue", () => { - expect( - buildQueueReorderMenuItems(ViewState.Player, [ep("A")], ep("A")), - ).toEqual([]); + expect(buildQueueReorderMenuItems(ViewState.Player, [ep("A")], ep("A"))).toEqual([]); }); test("a middle item offers all four moves with its index", () => { - const items = buildQueueReorderMenuItems( - ViewState.Player, - queueEpisodes, - ep("B"), - ); + const items = buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("B")); expect(titles(items)).toEqual([ "Move to top of queue", @@ -58,22 +50,14 @@ describe("buildQueueReorderMenuItems", () => { }); test("the first item omits the upward moves", () => { - const items = buildQueueReorderMenuItems( - ViewState.Player, - queueEpisodes, - ep("A"), - ); + const items = buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("A")); expect(items.map((i) => i.kind)).toEqual(["down", "bottom"]); expect(items.every((i) => i.index === 0)).toBe(true); }); test("the last item omits the downward moves", () => { - const items = buildQueueReorderMenuItems( - ViewState.Player, - queueEpisodes, - ep("C"), - ); + const items = buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("C")); expect(items.map((i) => i.kind)).toEqual(["top", "up"]); expect(items.every((i) => i.index === 2)).toBe(true); diff --git a/src/ui/PodcastView/spawnEpisodeContextMenu.ts b/src/ui/PodcastView/spawnEpisodeContextMenu.ts index 86616692..fcce7192 100644 --- a/src/ui/PodcastView/spawnEpisodeContextMenu.ts +++ b/src/ui/PodcastView/spawnEpisodeContextMenu.ts @@ -1,10 +1,18 @@ import { Menu, Notice } from "obsidian"; import createPodcastNote, { getPodcastNote, openPodcastNote } from "src/createPodcastNote"; import createFeedNote, { getFeedNote, openFeedNote } from "src/createFeedNote"; -import downloadEpisodeWithProgessNotice, { - removeDownloadedEpisode, -} from "src/downloadEpisode"; -import { currentEpisode, downloadedEpisodes, favorites, playedEpisodes, playlists, plugin, queue, savedFeeds, viewState } from "src/store"; +import downloadEpisodeWithProgessNotice, { removeDownloadedEpisode } from "src/downloadEpisode"; +import { + currentEpisode, + downloadedEpisodes, + favorites, + playedEpisodes, + playlists, + plugin, + queue, + savedFeeds, + viewState, +} from "src/store"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { ViewState } from "src/types/ViewState"; @@ -41,125 +49,143 @@ export default function spawnEpisodeContextMenu( const menu = new Menu(); if (!disabledMenuItems?.play) { - menu.addItem(item => item - .setIcon("play") - .setTitle("Play") - .onClick(() => { - currentEpisode.set(episode); - viewState.set(ViewState.Player); - })); + menu.addItem((item) => + item + .setIcon("play") + .setTitle("Play") + .onClick(() => { + currentEpisode.set(episode); + viewState.set(ViewState.Player); + }), + ); } if (!disabledMenuItems?.markPlayed) { const playedEpisodeMap = get(playedEpisodes); const episodeIsPlayed = playedEpisodeKey - ? playedEpisodeMap[playedEpisodeKey]?.finished ?? isEpisodeFinished(episode, playedEpisodeMap) + ? (playedEpisodeMap[playedEpisodeKey]?.finished ?? + isEpisodeFinished(episode, playedEpisodeMap)) : isEpisodeFinished(episode, playedEpisodeMap); - menu.addItem(item => item - .setIcon(episodeIsPlayed ? "x" : "check") - .setTitle(`Mark as ${episodeIsPlayed ? "Unplayed" : "Played"}`) - .onClick(() => { - if (episodeIsPlayed) { - if (playedEpisodeKey) { - playedEpisodes.markKeyAsUnplayed(playedEpisodeKey); + menu.addItem((item) => + item + .setIcon(episodeIsPlayed ? "x" : "check") + .setTitle(`Mark as ${episodeIsPlayed ? "Unplayed" : "Played"}`) + .onClick(() => { + if (episodeIsPlayed) { + if (playedEpisodeKey) { + playedEpisodes.markKeyAsUnplayed(playedEpisodeKey); + } else { + playedEpisodes.markAsUnplayed(episode); + } } else { - playedEpisodes.markAsUnplayed(episode); + playedEpisodes.markAsPlayed(episode); } - } else { - playedEpisodes.markAsPlayed(episode); - } - }) + }), ); } if (!disabledMenuItems?.download) { const isDownloaded = downloadedEpisodes.isEpisodeDownloaded(episode); - menu.addItem(item => item - .setIcon(isDownloaded ? "cross" : "download") - .setTitle(isDownloaded ? "Remove file" : "Download") - .onClick(() => { - if (isDownloaded) { - void removeDownloadedEpisode(episode); - } else { - // The path template always yields a per-episode file via - // safeDownloadBasename (#183), so no empty-path guard is needed — - // this matches the Download command in main.ts. Settle the - // promise so a failure can't surface as an unhandled rejection - // (the in-notice error is the user-facing message), matching the - // adjacent void removeDownloadedEpisode(...). - void downloadEpisodeWithProgessNotice( - episode, - get(plugin).settings.download.path, - ).catch((e) => console.error("PodNotes: download failed", e)); - } - })); + menu.addItem((item) => + item + .setIcon(isDownloaded ? "cross" : "download") + .setTitle(isDownloaded ? "Remove file" : "Download") + .onClick(() => { + if (isDownloaded) { + void removeDownloadedEpisode(episode); + } else { + // The path template always yields a per-episode file via + // safeDownloadBasename (#183), so no empty-path guard is needed — + // this matches the Download command in main.ts. Settle the + // promise so a failure can't surface as an unhandled rejection + // (the in-notice error is the user-facing message), matching the + // adjacent void removeDownloadedEpisode(...). + void downloadEpisodeWithProgessNotice( + episode, + get(plugin).settings.download.path, + ).catch((e) => console.error("PodNotes: download failed", e)); + } + }), + ); } if (!disabledMenuItems?.createNote) { const episodeNoteExists = Boolean(getPodcastNote(episode)); - menu.addItem(item => item - .setIcon("pencil") - .setTitle(`${episodeNoteExists ? "Open" : "Create"} Note`) - .onClick(async () => { - if (episodeNoteExists) { - openPodcastNote(episode); - } else { - const { path, template } = get(plugin).settings.note; - const canCreateNote = Boolean(path && template); + menu.addItem((item) => + item + .setIcon("pencil") + .setTitle(`${episodeNoteExists ? "Open" : "Create"} Note`) + .onClick(async () => { + if (episodeNoteExists) { + openPodcastNote(episode); + } else { + const { path, template } = get(plugin).settings.note; + const canCreateNote = Boolean(path && template); - if (!canCreateNote) { - new Notice(`Please set a note path and template in the settings.`); - return; - } + if (!canCreateNote) { + new Notice(`Please set a note path and template in the settings.`); + return; + } - await createPodcastNote(episode); - } - })); + await createPodcastNote(episode); + } + }), + ); // Feed-level note for the episode's parent podcast (issue #163). const feed = resolveFeedForEpisode(episode); const feedNoteExists = Boolean(getFeedNote(feed)); - menu.addItem(item => item - .setIcon("rss") - .setTitle(`${feedNoteExists ? "Open" : "Create"} feed note`) - .onClick(async () => { - if (feedNoteExists) { - openFeedNote(feed); - } else { - const { path, template } = get(plugin).settings.feedNote; - if (!path || !template) { - new Notice(`Please set a podcast feed note path and template in the settings.`); - return; - } + menu.addItem((item) => + item + .setIcon("rss") + .setTitle(`${feedNoteExists ? "Open" : "Create"} feed note`) + .onClick(async () => { + if (feedNoteExists) { + openFeedNote(feed); + } else { + const { path, template } = get(plugin).settings.feedNote; + if (!path || !template) { + new Notice( + `Please set a podcast feed note path and template in the settings.`, + ); + return; + } - await createFeedNote(feed); - } - })); + await createFeedNote(feed); + } + }), + ); } if (!disabledMenuItems?.favorite) { - const episodeIsFavorite = get(favorites).episodes.find(e => isSameStoredEpisode(e, episode)); - menu.addItem(item => item - .setIcon("lucide-star") - .setTitle(`${episodeIsFavorite ? "Remove from" : "Add to"} Favorites`) - .onClick(() => { - if (episodeIsFavorite) { - favorites.update(playlist => { - playlist.episodes = playlist.episodes.filter(e => !isSameStoredEpisode(e, episode)); - return playlist; - }); - } else { - favorites.update(playlist => { - const newEpisodes = [...playlist.episodes, episode]; - playlist.episodes = newEpisodes; + const episodeIsFavorite = get(favorites).episodes.find((e) => + isSameStoredEpisode(e, episode), + ); + menu.addItem((item) => + item + .setIcon("lucide-star") + .setTitle(`${episodeIsFavorite ? "Remove from" : "Add to"} Favorites`) + .onClick(() => { + if (episodeIsFavorite) { + favorites.update((playlist) => { + playlist.episodes = playlist.episodes.filter( + (e) => !isSameStoredEpisode(e, episode), + ); + return playlist; + }); + } else { + favorites.update((playlist) => { + const newEpisodes = [...playlist.episodes, episode]; + playlist.episodes = newEpisodes; - return playlist; - }); - } - })); + return playlist; + }); + } + }), + ); } if (!disabledMenuItems?.queue) { @@ -168,19 +194,19 @@ export default function spawnEpisodeContextMenu( // check and the reorder lookup below must match by title too. A composite-key // check would offer "Add to Queue" for a same-titled episode from another // podcast, but queue.add would then no-op (Codex review #214). - const episodeIsInQueue = get(queue).episodes.find( - (e) => e.title === episode.title, + const episodeIsInQueue = get(queue).episodes.find((e) => e.title === episode.title); + menu.addItem((item) => + item + .setIcon("list-ordered") + .setTitle(`${episodeIsInQueue ? "Remove from" : "Add to"} Queue`) + .onClick(() => { + if (episodeIsInQueue) { + queue.remove(episode); + } else { + queue.add(episode); + } + }), ); - menu.addItem(item => item - .setIcon("list-ordered") - .setTitle(`${episodeIsInQueue ? "Remove from" : "Add to"} Queue`) - .onClick(() => { - if (episodeIsInQueue) { - queue.remove(episode); - } else { - queue.add(episode); - } - })); // Reorder controls — only meaningful when viewing the queue as an ordered // list in the Player. spawnEpisodeContextMenu is shared with the feed, @@ -195,33 +221,35 @@ export default function spawnEpisodeContextMenu( menu.addSeparator(); for (const reorderItem of reorderItems) { - menu.addItem(item => item - .setIcon(reorderItem.icon) - .setTitle(reorderItem.title) - .onClick(() => { - // Resolve the index live: the queue can shift (e.g. the - // episode ends and playNext advances it) between opening - // the menu and clicking. - const index = get(queue).episodes.findIndex( - (e) => e.title === episode.title, - ); - if (index === -1) return; + menu.addItem((item) => + item + .setIcon(reorderItem.icon) + .setTitle(reorderItem.title) + .onClick(() => { + // Resolve the index live: the queue can shift (e.g. the + // episode ends and playNext advances it) between opening + // the menu and clicking. + const index = get(queue).episodes.findIndex( + (e) => e.title === episode.title, + ); + if (index === -1) return; - switch (reorderItem.kind) { - case "top": - queue.moveToTop(index); - break; - case "up": - queue.moveUp(index); - break; - case "down": - queue.moveDown(index); - break; - case "bottom": - queue.moveToBottom(index); - break; - } - })); + switch (reorderItem.kind) { + case "top": + queue.moveToTop(index); + break; + case "up": + queue.moveUp(index); + break; + case "down": + queue.moveDown(index); + break; + case "bottom": + queue.moveToBottom(index); + break; + } + }), + ); } } } @@ -237,27 +265,38 @@ export default function spawnEpisodeContextMenu( menu.addSeparator(); for (const playlist of entries) { - const episodeIsInPlaylist = playlist.episodes.find(e => isSameStoredEpisode(e, episode)); + const episodeIsInPlaylist = playlist.episodes.find((e) => + isSameStoredEpisode(e, episode), + ); - menu.addItem(item => item - .setIcon(playlist.icon) - .setTitle(`${episodeIsInPlaylist ? "Remove from" : "Add to"} ${playlist.name}`) - .onClick(() => { - if (episodeIsInPlaylist) { - playlists.update(playlists => { - playlists[playlist.name].episodes = playlists[playlist.name].episodes.filter(e => !isSameStoredEpisode(e, episode)); + menu.addItem((item) => + item + .setIcon(playlist.icon) + .setTitle( + `${episodeIsInPlaylist ? "Remove from" : "Add to"} ${playlist.name}`, + ) + .onClick(() => { + if (episodeIsInPlaylist) { + playlists.update((playlists) => { + playlists[playlist.name].episodes = playlists[ + playlist.name + ].episodes.filter((e) => !isSameStoredEpisode(e, episode)); - return playlists; - }); - } else { - playlists.update(playlists => { - const newEpisodes = [...playlists[playlist.name].episodes, episode]; - playlists[playlist.name].episodes = newEpisodes; + return playlists; + }); + } else { + playlists.update((playlists) => { + const newEpisodes = [ + ...playlists[playlist.name].episodes, + episode, + ]; + playlists[playlist.name].episodes = newEpisodes; - return playlists; - }); - } - })); + return playlists; + }); + } + }), + ); } } } diff --git a/src/ui/QueueReorderModal.test.ts b/src/ui/QueueReorderModal.test.ts index aada0f9d..e0b8045e 100644 --- a/src/ui/QueueReorderModal.test.ts +++ b/src/ui/QueueReorderModal.test.ts @@ -30,9 +30,7 @@ describe("QueueReorderModal", () => { modal.open(); expect(modal.titleEl.textContent).toBe("Reorder Queue"); - expect( - modal.contentEl.querySelectorAll(".queue-reorder-item").length, - ).toBe(3); + expect(modal.contentEl.querySelectorAll(".queue-reorder-item").length).toBe(3); modal.close(); expect(modal.contentEl.querySelector(".queue-reorder-item")).toBeNull(); diff --git a/src/ui/ReorderQueue.test.ts b/src/ui/ReorderQueue.test.ts index 52386f4c..3e3f0ca1 100644 --- a/src/ui/ReorderQueue.test.ts +++ b/src/ui/ReorderQueue.test.ts @@ -35,14 +35,14 @@ describe("ReorderQueue", () => { seed("A", "B", "C"); const { container } = render(ReorderQueue); - const renderedTitles = Array.from( - container.querySelectorAll(".queue-reorder-title"), - ).map((el) => el.textContent); + const renderedTitles = Array.from(container.querySelectorAll(".queue-reorder-title")).map( + (el) => el.textContent, + ); expect(renderedTitles).toEqual(["A", "B", "C"]); - const positions = Array.from( - container.querySelectorAll(".queue-reorder-position"), - ).map((el) => el.textContent); + const positions = Array.from(container.querySelectorAll(".queue-reorder-position")).map( + (el) => el.textContent, + ); expect(positions).toEqual(["1", "2", "3"]); }); @@ -91,9 +91,7 @@ describe("ReorderQueue", () => { const { getAllByLabelText } = render(ReorderQueue); const firstMoveUp = getAllByLabelText("Move up")[0] as HTMLButtonElement; - const firstMoveToTop = getAllByLabelText( - "Move to top", - )[0] as HTMLButtonElement; + const firstMoveToTop = getAllByLabelText("Move to top")[0] as HTMLButtonElement; expect(firstMoveUp).toBeDisabled(); expect(firstMoveToTop).toBeDisabled(); @@ -107,9 +105,7 @@ describe("ReorderQueue", () => { const { getAllByLabelText } = render(ReorderQueue); const lastMoveDown = getAllByLabelText("Move down")[2] as HTMLButtonElement; - const lastMoveToBottom = getAllByLabelText( - "Move to bottom", - )[2] as HTMLButtonElement; + const lastMoveToBottom = getAllByLabelText("Move to bottom")[2] as HTMLButtonElement; expect(lastMoveDown).toBeDisabled(); expect(lastMoveToBottom).toBeDisabled(); diff --git a/src/ui/common/Progressbar.test.ts b/src/ui/common/Progressbar.test.ts index 1df9a2f9..4f718410 100644 --- a/src/ui/common/Progressbar.test.ts +++ b/src/ui/common/Progressbar.test.ts @@ -30,9 +30,7 @@ describe("Progressbar", () => { await fireEvent.keyDown(slider, { key: "ArrowRight" }); await fireEvent.keyDown(slider, { key: "Home" }); - const [incrementEvent, homeEvent] = handler.mock.calls.map( - (call) => call[0].detail, - ); + const [incrementEvent, homeEvent] = handler.mock.calls.map((call) => call[0].detail); expect(incrementEvent.percent).toBeCloseTo(0.25, 5); expect(homeEvent.percent).toBe(0); diff --git a/src/ui/settings/PlaylistManager.test.ts b/src/ui/settings/PlaylistManager.test.ts index 40bc1f4a..fc088387 100644 --- a/src/ui/settings/PlaylistManager.test.ts +++ b/src/ui/settings/PlaylistManager.test.ts @@ -99,9 +99,7 @@ describe("PlaylistManager", () => { const stored = get(playlists); expect(Object.keys(stored)).toEqual(["Favorites Mix"]); expect(stored["Favorites Mix"]).toBe(existing); - expect(noticeSpy).toHaveBeenCalledWith( - "A playlist with that name already exists.", - ); + expect(noticeSpy).toHaveBeenCalledWith("A playlist with that name already exists."); // The duplicate name was rejected, so the input is left for correction. expect(input.value).toBe("Favorites Mix"); }); @@ -126,8 +124,6 @@ describe("PlaylistManager", () => { await fireEvent.click(getByRole("button", { name: "Add playlist" })); expect(Object.keys(get(playlists))).toEqual(["Daily"]); - expect(noticeSpy).toHaveBeenCalledWith( - "A playlist with that name already exists.", - ); + expect(noticeSpy).toHaveBeenCalledWith("A playlist with that name already exists."); }); }); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 7a64e3b3..0519664d 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -31,10 +31,7 @@ import { savedFeeds, volume, } from "src/store/index"; -import { - DEFAULT_EPISODE_LIST_LIMIT, - MAX_EPISODE_LIST_LIMIT, -} from "src/constants"; +import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; @@ -48,10 +45,7 @@ import { serializeSettings, } from "src/settingsTransfer"; import { normalizePlaybackRate } from "src/utility/playbackRate"; -import { - DEFAULT_SPEAKER_TEMPLATE, - type DiarizationProviderId, -} from "src/services/diarization"; +import { DEFAULT_SPEAKER_TEMPLATE, type DiarizationProviderId } from "src/services/diarization"; /** * Stack a Setting's control beneath its name, full width — the layout the @@ -154,15 +148,13 @@ export class PodNotesSettingsTab extends PluginSettingTab { "When on, the episode you switch away from is kept at the top of the queue and playback automatically continues with the next queued episode when one ends. Turn this off to stop the queue from filling and advancing on its own — you can still add episodes to the queue manually.", ) .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.autoQueue) - .onChange(async (value) => { - this.plugin.settings.autoQueue = value; - await this.plugin.saveSettings(); - // Re-emit the plugin store so an open player/grid recomputes - // the Queue tile/list visibility immediately (issue #108). - plugin.set(this.plugin); - }), + toggle.setValue(this.plugin.settings.autoQueue).onChange(async (value) => { + this.plugin.settings.autoQueue = value; + await this.plugin.saveSettings(); + // Re-emit the plugin store so an open player/grid recomputes + // the Queue tile/list visibility immediately (issue #108). + plugin.set(this.plugin); + }), ); } @@ -177,9 +169,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { textComponent.inputEl.min = "1"; textComponent.inputEl.max = `${MAX_EPISODE_LIST_LIMIT}`; textComponent - .setValue( - `${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`, - ) + .setValue(`${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`) .setPlaceholder(`${DEFAULT_EPISODE_LIST_LIMIT}`) .onChange(async (value) => { // Don't commit while the field is empty or mid-edit (e.g. cleared, @@ -205,18 +195,16 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private addDefaultPlaybackRateSetting(container: HTMLElement): void { - new Setting(container) - .setName("Default Playback Rate") - .addSlider((slider) => - slider - .setLimits(0.5, 4, 0.1) - .setValue(this.plugin.settings.defaultPlaybackRate) - .onChange((value) => { - this.plugin.settings.defaultPlaybackRate = value; - void this.plugin.saveSettings(); - }) - .setDynamicTooltip(), - ); + new Setting(container).setName("Default Playback Rate").addSlider((slider) => + slider + .setLimits(0.5, 4, 0.1) + .setValue(this.plugin.settings.defaultPlaybackRate) + .onChange((value) => { + this.plugin.settings.defaultPlaybackRate = value; + void this.plugin.saveSettings(); + }) + .setDynamicTooltip(), + ); } private addDefaultVolumeSetting(container: HTMLElement): void { @@ -236,37 +224,33 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private addSkipLengthSettings(container: HTMLElement): void { - new Setting(container) - .setName("Skip backward length (s)") - .addText((textComponent) => { - textComponent.inputEl.type = "number"; - textComponent - .setValue(`${this.plugin.settings.skipBackwardLength}`) - .onChange((value) => { - // Ignore empty/invalid input instead of persisting NaN, which - // would corrupt the playback position when skipping (PB-02/ST-01). - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) return; - this.plugin.settings.skipBackwardLength = parsed; - void this.plugin.saveSettings(); - }) - .setPlaceholder("seconds"); - }); + new Setting(container).setName("Skip backward length (s)").addText((textComponent) => { + textComponent.inputEl.type = "number"; + textComponent + .setValue(`${this.plugin.settings.skipBackwardLength}`) + .onChange((value) => { + // Ignore empty/invalid input instead of persisting NaN, which + // would corrupt the playback position when skipping (PB-02/ST-01). + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return; + this.plugin.settings.skipBackwardLength = parsed; + void this.plugin.saveSettings(); + }) + .setPlaceholder("seconds"); + }); - new Setting(container) - .setName("Skip forward length (s)") - .addText((textComponent) => { - textComponent.inputEl.type = "number"; - textComponent - .setValue(`${this.plugin.settings.skipForwardLength}`) - .onChange((value) => { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) return; - this.plugin.settings.skipForwardLength = parsed; - void this.plugin.saveSettings(); - }) - .setPlaceholder("seconds"); - }); + new Setting(container).setName("Skip forward length (s)").addText((textComponent) => { + textComponent.inputEl.type = "number"; + textComponent + .setValue(`${this.plugin.settings.skipForwardLength}`) + .onChange((value) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return; + this.plugin.settings.skipForwardLength = parsed; + void this.plugin.saveSettings(); + }) + .setPlaceholder("seconds"); + }); } private addNoteSettings(settingsContainer: HTMLDivElement) { @@ -297,9 +281,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { // instead of leaving the preview blank so the control isn't mistaken // for broken (TS-04). if (!this.plugin.api.podcast) { - timestampFormatDemoEl.setText( - "Play an episode to preview the timestamp format.", - ); + timestampFormatDemoEl.setText("Play an episode to preview the timestamp format."); return; } @@ -336,9 +318,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setHeading() .addText((textComponent) => { textComponent.setValue(this.plugin.settings.note.path); - textComponent.setPlaceholder( - "inputs/podcasts/{{podcast}} - {{title}}.md", - ); + textComponent.setPlaceholder("inputs/podcasts/{{podcast}} - {{title}}.md"); textComponent.onChange((value) => { this.plugin.settings.note.path = value; void this.plugin.saveSettings(); @@ -392,7 +372,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { const desc = container.createEl("p", { text: - 'Create a note for a whole podcast (the feed), not a single episode. ' + + "Create a note for a whole podcast (the feed), not a single episode. " + 'Run the "Create podcast feed note" command to pick a saved podcast. ' + "Available tags: {{title}}, {{podcast}}, {{url}} (website), " + "{{feedurl}} (RSS), {{artwork}}, {{author}}, {{description}}, {{date}}.", @@ -499,12 +479,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setName("Cache podcast feeds") .setDesc("Store recently downloaded feeds locally for faster startup.") .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.feedCache.enabled) - .onChange(async (value) => { - this.plugin.settings.feedCache.enabled = value; - await this.plugin.saveSettings(); - }), + toggle.setValue(this.plugin.settings.feedCache.enabled).onChange(async (value) => { + this.plugin.settings.feedCache.enabled = value; + await this.plugin.saveSettings(); + }), ); new Setting(container) @@ -525,13 +503,11 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setName("Clear cached feeds") .setDesc("Remove stored feed data. PodNotes will refetch feeds as needed.") .addButton((button) => - button - .setButtonText("Clear cache") - .onClick(() => { - clearFeedCache(); - episodeCache.set({}); - new Notice("Cleared cached podcast feeds."); - }), + button.setButtonText("Clear cache").onClick(() => { + clearFeedCache(); + episodeCache.set({}); + new Notice("Cleared cached podcast feeds."); + }), ); } @@ -604,9 +580,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { ) .addButton((button) => button.setButtonText("Import").onClick(() => { - this.pickFile(".json", (contents) => - this.handleSettingsImport(contents), - ); + this.pickFile(".json", (contents) => this.handleSettingsImport(contents)); }), ); @@ -654,8 +628,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { // import doesn't inherit the settings-file copy. const maxBytes = options.maxBytes ?? 5 * 1024 * 1024; const tooLargeMessage = - options.tooLargeMessage ?? - "That file is too large to be a PodNotes settings file."; + options.tooLargeMessage ?? "That file is too large to be a PodNotes settings file."; const fileInput = activeDocument.createElement("input"); fileInput.type = "file"; @@ -698,10 +671,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { fileInput.click(); } - private async handleSettingsExport( - fileName: string, - includeSecret: boolean, - ): Promise<void> { + private async handleSettingsExport(fileName: string, includeSecret: boolean): Promise<void> { try { const envelope = serializeSettings( this.plugin.settings, @@ -715,16 +685,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { // vault file (which could be an unrelated note) without the user // choosing a fresh name. if (this.app.vault.getAbstractFileByPath(fileName)) { - new Notice( - `A file named "${fileName}" already exists. Choose a different name.`, - ); + new Notice(`A file named "${fileName}" already exists. Choose a different name.`); return; } await this.app.vault.create(fileName, contents); - const exportedSecrets = includeSecret - ? describeSecrets(this.plugin.settings) - : []; + const exportedSecrets = includeSecret ? describeSecrets(this.plugin.settings) : []; new Notice( exportedSecrets.length ? `Exported PodNotes settings to "${fileName}" (includes your ${exportedSecrets.join(" and ")}).` @@ -769,9 +735,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { sections.push("playlists"); } sections.push(...describeSecrets(result.settings)); - const detail = sections.length - ? ` This also replaces your ${sections.join(", ")}.` - : ""; + const detail = sections.length ? ` This also replaces your ${sections.join(", ")}.` : ""; new ConfirmModal( this.app, @@ -784,9 +748,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { ).open(); } - private async applyImportedSettings( - imported: Partial<IPodNotesSettings>, - ): Promise<void> { + private async applyImportedSettings(imported: Partial<IPodNotesSettings>): Promise<void> { const merged = mergeImportedSettings(this.plugin.settings, imported); this.plugin.settings = merged; @@ -802,9 +764,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { const sanitizedLimit = sanitizeEpisodeListLimit(merged.episodeListLimit); merged.episodeListLimit = sanitizedLimit; episodeListLimit.set(sanitizedLimit); - const importedVolume = Number.isFinite(merged.defaultVolume) - ? merged.defaultVolume - : 1; + const importedVolume = Number.isFinite(merged.defaultVolume) ? merged.defaultVolume : 1; volume.set(Math.min(1, Math.max(0, importedVolume))); playbackRate.set(normalizePlaybackRate(merged.defaultPlaybackRate)); @@ -828,8 +788,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setName("OpenAI API Key") .setDesc("Enter your OpenAI API key for transcription functionality.") .addText((text) => { - text - .setPlaceholder("Enter your OpenAI API key") + text.setPlaceholder("Enter your OpenAI API key") .setValue(this.plugin.settings.openAIApiKey) .onChange(async (value) => { this.plugin.settings.openAIApiKey = value; @@ -840,12 +799,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { new Setting(container) .setName("Transcript file path") - .setDesc( - "The path where transcripts will be saved. Use {{}} for dynamic values.", - ) + .setDesc("The path where transcripts will be saved. Use {{}} for dynamic values.") .addText((text) => { - text - .setPlaceholder("transcripts/{{podcast}}/{{title}}.md") + text.setPlaceholder("transcripts/{{podcast}}/{{title}}.md") .setValue(this.plugin.settings.transcript.path) .onChange(async (value) => { this.plugin.settings.transcript.path = value; @@ -869,10 +825,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setDesc("The template for the transcript file content.") .setHeading() .addTextArea((text) => { - text - .setPlaceholder( - "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\nURL: {{url}}\n\n## Description\n\n{{description}}\n\n## Transcript\n\n{{transcript}}", - ) + text.setPlaceholder( + "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\nURL: {{url}}\n\n## Description\n\n{{description}}\n\n## Transcript\n\n{{transcript}}", + ) .setValue(this.plugin.settings.transcript.template) .onChange(async (value) => { this.plugin.settings.transcript.template = value; @@ -935,12 +890,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { if (diarization.provider === "deepgram") { new Setting(diarizationContainer) .setName("Deepgram API key") - .setDesc( - "Used only for Deepgram diarization. Create one at deepgram.com.", - ) + .setDesc("Used only for Deepgram diarization. Create one at deepgram.com.") .addText((text) => { - text - .setPlaceholder("Enter your Deepgram API key") + text.setPlaceholder("Enter your Deepgram API key") .setValue(this.plugin.settings.diarizationApiKey) .onChange(async (value) => { this.plugin.settings.diarizationApiKey = value; @@ -960,8 +912,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setPlaceholder(DEFAULT_SPEAKER_TEMPLATE) .setValue(diarization.speakerTemplate) .onChange(async (value) => { - this.plugin.settings.transcript.diarization.speakerTemplate = - value; + this.plugin.settings.transcript.diarization.speakerTemplate = value; await this.plugin.saveSettings(); }), ); @@ -987,12 +938,10 @@ function getRandomEpisode(): Episode { const feedEpisodes = Object.values(get(episodeCache)); if (!feedEpisodes.length) return fallbackDemoObj; - const randomFeed = - feedEpisodes[Math.floor(Math.random() * feedEpisodes.length)]; + const randomFeed = feedEpisodes[Math.floor(Math.random() * feedEpisodes.length)]; if (!randomFeed.length) return fallbackDemoObj; - const randomEpisode = - randomFeed[Math.floor(Math.random() * randomFeed.length)]; + const randomEpisode = randomFeed[Math.floor(Math.random() * randomFeed.length)]; return randomEpisode; } @@ -1019,13 +968,7 @@ class ConfirmModal extends Modal { private confirmText: string; private onConfirm: () => void; - constructor( - app: App, - title: string, - body: string, - confirmText: string, - onConfirm: () => void, - ) { + constructor(app: App, title: string, body: string, confirmText: string, onConfirm: () => void) { super(app); this.title = title; this.body = body; @@ -1039,9 +982,7 @@ class ConfirmModal extends Modal { contentEl.createEl("p", { text: this.body }); new Setting(contentEl) - .addButton((button) => - button.setButtonText("Cancel").onClick(() => this.close()), - ) + .addButton((button) => button.setButtonText("Cancel").onClick(() => this.close())) .addButton((button) => button .setButtonText(this.confirmText) diff --git a/src/utility/addExtension.test.ts b/src/utility/addExtension.test.ts index 1699b126..760c14ac 100644 --- a/src/utility/addExtension.test.ts +++ b/src/utility/addExtension.test.ts @@ -2,16 +2,16 @@ import { expect, describe, test } from "vitest"; import addExtension from "./addExtension"; describe("addExtension", () => { - test("adds an extension to a file path", () => { - expect(addExtension("path/to/file", "md")).toBe("path/to/file.md"); - }); - - test("does not add an extension if the file path already has one", () => { - expect(addExtension("path/to/file.md", "md")).toBe("path/to/file.md"); - }); + test("adds an extension to a file path", () => { + expect(addExtension("path/to/file", "md")).toBe("path/to/file.md"); + }); - test("works with and without dot in extension", () => { - expect(addExtension("path/to/file", ".md")).toBe("path/to/file.md"); - expect(addExtension("path/to/file.md", "md")).toBe("path/to/file.md"); - }); -}); \ No newline at end of file + test("does not add an extension if the file path already has one", () => { + expect(addExtension("path/to/file.md", "md")).toBe("path/to/file.md"); + }); + + test("works with and without dot in extension", () => { + expect(addExtension("path/to/file", ".md")).toBe("path/to/file.md"); + expect(addExtension("path/to/file.md", "md")).toBe("path/to/file.md"); + }); +}); diff --git a/src/utility/assertFetchableUrl.test.ts b/src/utility/assertFetchableUrl.test.ts index 98f63d00..9bb7450e 100644 --- a/src/utility/assertFetchableUrl.test.ts +++ b/src/utility/assertFetchableUrl.test.ts @@ -1,18 +1,12 @@ import { describe, expect, it } from "vitest"; -import { - assertFetchableUrl, - isFetchableUrl, - UnsafeFetchUrlError, -} from "./assertFetchableUrl"; +import { assertFetchableUrl, isFetchableUrl, UnsafeFetchUrlError } from "./assertFetchableUrl"; describe("assertFetchableUrl", () => { it("accepts ordinary public http(s) feed/enclosure URLs", () => { expect(() => assertFetchableUrl("https://pod.example.com/audio.mp3?token=abc"), ).not.toThrow(); - expect(() => - assertFetchableUrl("http://cdn.example.org/ep/1.mp3"), - ).not.toThrow(); + expect(() => assertFetchableUrl("http://cdn.example.org/ep/1.mp3")).not.toThrow(); // A public IP is fine. expect(() => assertFetchableUrl("https://8.8.8.8/feed.xml")).not.toThrow(); }); @@ -32,14 +26,12 @@ describe("assertFetchableUrl", () => { expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); }); - it.each([ - "", - " ", - "not a url", - "//example.com/no-scheme", - ])("rejects empty/malformed url %s", (url) => { - expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); - }); + it.each(["", " ", "not a url", "//example.com/no-scheme"])( + "rejects empty/malformed url %s", + (url) => { + expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + }, + ); it.each([ "http://localhost/feed.xml", diff --git a/src/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts index 1d4cbe08..19f013ca 100644 --- a/src/utility/buildEpisodeResumeLink.test.ts +++ b/src/utility/buildEpisodeResumeLink.test.ts @@ -29,9 +29,7 @@ describe("buildEpisodeResumeLink (#35)", () => { expect(parsed.protocol).toBe("obsidian:"); expect(parsed.host).toBe("podnotes"); expect(parsed.searchParams.get("episodeName")).toBe("Episode 1"); - expect(parsed.searchParams.get("url")).toBe( - "https://pod.example.com/feed.xml", - ); + expect(parsed.searchParams.get("url")).toBe("https://pod.example.com/feed.xml"); // No time => URIHandler resumes from the last played location. expect(parsed.searchParams.has("time")).toBe(false); }); @@ -76,9 +74,7 @@ describe("buildEpisodeResumeLink (#35)", () => { // A normal podcast episode resolves remotely; downloaded availability is the // player's concern, so the link still addresses it by feed URL. - expect(parsed.searchParams.get("url")).toBe( - "https://pod.example.com/feed.xml", - ); + expect(parsed.searchParams.get("url")).toBe("https://pod.example.com/feed.xml"); }); test("falls back to a downloaded copy's path for a non-local episode with no feed URL", () => { @@ -107,8 +103,6 @@ describe("buildEpisodeResumeLink (#35)", () => { // Obsidian decodes with decodeURIComponent only; encodePodnotesURI emits // %2B (not '+') so the title decodes back to a literal '+'. expect(link).toContain("Episode%2050%3A%20C%2B%2B%20Tips"); - expect(new URL(link).searchParams.get("episodeName")).toBe( - "Episode 50: C++ Tips", - ); + expect(new URL(link).searchParams.get("episodeName")).toBe("Episode 50: C++ Tips"); }); }); diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index add11840..be95e7b2 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -20,8 +20,8 @@ import { isLocalFile } from "./isLocalFile"; export default function buildEpisodeResumeLink(episode: Episode): string { const downloadedPath = () => downloadedEpisodes.getEpisode(episode)?.filePath; const target = isLocalFile(episode) - ? episode.filePath ?? downloadedPath() - : episode.feedUrl ?? downloadedPath(); + ? (episode.filePath ?? downloadedPath()) + : (episode.feedUrl ?? downloadedPath()); // URIHandler rejects an empty episodeName, so without a title there is no // working link to emit — degrade to "" rather than a dead link (UR-03). diff --git a/src/utility/checkStringIsUrl.test.ts b/src/utility/checkStringIsUrl.test.ts index 5f90545d..23452122 100644 --- a/src/utility/checkStringIsUrl.test.ts +++ b/src/utility/checkStringIsUrl.test.ts @@ -2,15 +2,15 @@ import { expect, test, describe } from "vitest"; import checkStringIsUrl from "./checkStringIsUrl"; describe("checkStringIsUrl", () => { - test("returns null if the string is not a valid URL", () => { - expect(checkStringIsUrl("not a url")).toBeNull(); - }); + test("returns null if the string is not a valid URL", () => { + expect(checkStringIsUrl("not a url")).toBeNull(); + }); - test("returns a URL object if the string is a valid URL", () => { - expect(checkStringIsUrl("https://example.com")).toBeInstanceOf(URL); - }); + test("returns a URL object if the string is a valid URL", () => { + expect(checkStringIsUrl("https://example.com")).toBeInstanceOf(URL); + }); - test("returns null on empty string", () => { - expect(checkStringIsUrl("")).toBeNull(); - }); -}); \ No newline at end of file + test("returns null on empty string", () => { + expect(checkStringIsUrl("")).toBeNull(); + }); +}); diff --git a/src/utility/createMediaUrlObjectFromFilePath.test.ts b/src/utility/createMediaUrlObjectFromFilePath.test.ts index 172dbdde..a5962def 100644 --- a/src/utility/createMediaUrlObjectFromFilePath.test.ts +++ b/src/utility/createMediaUrlObjectFromFilePath.test.ts @@ -20,9 +20,7 @@ function setupVault( const getResourcePath = vi.fn( opts.getResourcePath ?? ((file: TFile) => `app://resource/${file.path}?1`), ); - const getAbstractFileByPath = vi.fn( - opts.resolve ?? ((path: string) => tfile(path)), - ); + const getAbstractFileByPath = vi.fn(opts.resolve ?? ((path: string) => tfile(path))); const vault = { getAbstractFileByPath, getResourcePath } as unknown as Vault; @@ -81,9 +79,7 @@ describe("createMediaUrlObjectFromFilePath", () => { }); for (const path of ["a.wav", "b.flac", "c.m4a", "d.webm"]) { - expect(createMediaUrlObjectFromFilePath(vault, path)).toBe( - `app://resource/${path}`, - ); + expect(createMediaUrlObjectFromFilePath(vault, path)).toBe(`app://resource/${path}`); } expect(seen).toEqual(["a.wav", "b.flac", "c.m4a", "d.webm"]); diff --git a/src/utility/createMediaUrlObjectFromFilePath.ts b/src/utility/createMediaUrlObjectFromFilePath.ts index ab135e9f..c2bd0f0c 100644 --- a/src/utility/createMediaUrlObjectFromFilePath.ts +++ b/src/utility/createMediaUrlObjectFromFilePath.ts @@ -16,10 +16,7 @@ import { TFile, type Vault } from "obsidian"; * Returns an empty string when the path does not resolve to a vault file, matching * the prior behavior (binding `src=""` is a benign no-op on the audio element). */ -export function createMediaUrlObjectFromFilePath( - vault: Vault, - filePath: string, -): string { +export function createMediaUrlObjectFromFilePath(vault: Vault, filePath: string): string { const file = vault.getAbstractFileByPath(filePath); if (!file || !(file instanceof TFile)) return ""; diff --git a/src/utility/encodePodnotesURI.ts b/src/utility/encodePodnotesURI.ts index 300cd096..b8c05711 100644 --- a/src/utility/encodePodnotesURI.ts +++ b/src/utility/encodePodnotesURI.ts @@ -7,13 +7,13 @@ export default function encodePodnotesURI( const url = new URL(`obsidian://podnotes`); const params = new URLSearchParams(); - params.set('episodeName', title); - params.set('url', feedUrl); + params.set("episodeName", title); + params.set("url", feedUrl); if (time !== undefined) { - params.set('time', time.toString()); + params.set("time", time.toString()); if (endTime !== undefined) { - params.set('endTime', endTime.toString()); + params.set("endTime", endTime.toString()); } } @@ -21,7 +21,7 @@ export default function encodePodnotesURI( // '+' into a space. URLSearchParams serializes spaces as '+' and a literal '+' as '%2B', so // replacing '+' with '%20' yields plain percent-encoding that round-trips losslessly through // Obsidian's decoder — including titles or local-file paths that contain a literal '+'. - url.search = params.toString().replace(/\+/g, '%20'); + url.search = params.toString().replace(/\+/g, "%20"); return url; } diff --git a/src/utility/encodeUrlForRequest.test.ts b/src/utility/encodeUrlForRequest.test.ts index 8f3711ce..69937110 100644 --- a/src/utility/encodeUrlForRequest.test.ts +++ b/src/utility/encodeUrlForRequest.test.ts @@ -3,22 +3,19 @@ import { encodeUrlForRequest } from "./encodeUrlForRequest"; describe("encodeUrlForRequest", () => { it("encodes parentheses and whitespace while leaving structure intact", () => { - const raw = - "https://example.com/podcast/Episode (Part 1).mp3?token=(abc123)"; + const raw = "https://example.com/podcast/Episode (Part 1).mp3?token=(abc123)"; expect(encodeUrlForRequest(raw)).toBe( "https://example.com/podcast/Episode%20%28Part%201%29.mp3?token=%28abc123%29", ); }); it("returns already encoded urls untouched", () => { - const alreadyEncoded = - "https://example.com/audio/Episode%20%28Part%201%29.mp3"; + const alreadyEncoded = "https://example.com/audio/Episode%20%28Part%201%29.mp3"; expect(encodeUrlForRequest(alreadyEncoded)).toBe(alreadyEncoded); }); it("keeps intentionally encoded delimiters intact", () => { - const signedUrl = - "https://example.com/path%2Fsegment%3Fsignature%3Dabc123%23hash"; + const signedUrl = "https://example.com/path%2Fsegment%3Fsignature%3Dabc123%23hash"; expect(encodeUrlForRequest(signedUrl)).toBe(signedUrl); }); diff --git a/src/utility/encodeUrlForRequest.ts b/src/utility/encodeUrlForRequest.ts index 2ac8a401..298fc55e 100644 --- a/src/utility/encodeUrlForRequest.ts +++ b/src/utility/encodeUrlForRequest.ts @@ -21,14 +21,12 @@ export function encodeUrlForRequest(rawUrl: string): string { } const encoded = normalized; - return encoded.replace( - PARENTHESIS_REGEXP, - (char) => PARENTHESIS_LOOKUP[char] ?? char, - ); + return encoded.replace(PARENTHESIS_REGEXP, (char) => PARENTHESIS_LOOKUP[char] ?? char); } function encodeWhitespace(value: string): string { - return value.replace(/\s/g, (char) => - `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`, + return value.replace( + /\s/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`, ); } diff --git a/src/utility/enforceMaxPathLength.test.ts b/src/utility/enforceMaxPathLength.test.ts index 466e304a..501ad1ff 100644 --- a/src/utility/enforceMaxPathLength.test.ts +++ b/src/utility/enforceMaxPathLength.test.ts @@ -24,11 +24,7 @@ describe("enforceMaxPathLength", () => { it("caps an over-long ASCII file name at 255, keeping folders and extension", () => { const longTitle = "A".repeat(400); - const result = enforceMaxPathLength( - `PodNotes/My Show/${longTitle}.md`, - ".md", - BYTES, - ); + const result = enforceMaxPathLength(`PodNotes/My Show/${longTitle}.md`, ".md", BYTES); expect(result.startsWith("PodNotes/My Show/")).toBe(true); expect(result.endsWith(".md")).toBe(true); @@ -101,9 +97,9 @@ describe("enforceMaxPathLength", () => { }); it("drops empty segments from leading/trailing/double slashes", () => { - expect( - enforceMaxPathLength("PodNotes//My Show/Episode 1.md", ".md", BYTES), - ).toBe("PodNotes/My Show/Episode 1.md"); + expect(enforceMaxPathLength("PodNotes//My Show/Episode 1.md", ".md", BYTES)).toBe( + "PodNotes/My Show/Episode 1.md", + ); expect(enforceMaxPathLength("/PodNotes/Episode 1.md", ".md", BYTES)).toBe( "PodNotes/Episode 1.md", ); @@ -131,9 +127,7 @@ describe("enforceMaxPathLength", () => { it("preserves a non-.md extension when asked to (transcripts/downloads)", () => { // With an empty extension it does not force one (preserves a no-suffix path). - expect(enforceMaxPathLength("transcripts/Show/Ep", "", BYTES)).toBe( - "transcripts/Show/Ep", - ); + expect(enforceMaxPathLength("transcripts/Show/Ep", "", BYTES)).toBe("transcripts/Show/Ep"); expect(enforceMaxPathLength("transcripts/Show/Ep.txt", ".txt", BYTES)).toBe( "transcripts/Show/Ep.txt", ); @@ -192,11 +186,7 @@ describe("enforceMaxPathLength with template-derived extension (transcripts)", ( // rendered path. A dotted, very long title must NOT be mistaken for a huge // extension that escapes the cap (round-3 regression guard). const rendered = `transcripts/Show/Episode.Part.${"X".repeat(400)}`; - const result = enforceMaxPathLength( - rendered, - lastSegmentExtension(rendered), - BYTES, - ); + const result = enforceMaxPathLength(rendered, lastSegmentExtension(rendered), BYTES); const name = result.split("/").pop() ?? ""; expect(byteLength(name)).toBeLessThanOrEqual(MAX_FILENAME_UNITS); }); @@ -204,11 +194,7 @@ describe("enforceMaxPathLength with template-derived extension (transcripts)", ( it("preserves a real .md/.txt extension on a long title", () => { for (const ext of [".md", ".txt"]) { const rendered = `transcripts/Show/${"Y".repeat(400)}${ext}`; - const result = enforceMaxPathLength( - rendered, - lastSegmentExtension(rendered), - BYTES, - ); + const result = enforceMaxPathLength(rendered, lastSegmentExtension(rendered), BYTES); const name = result.split("/").pop() ?? ""; expect(name.endsWith(ext)).toBe(true); expect(byteLength(name)).toBeLessThanOrEqual(MAX_FILENAME_UNITS); @@ -219,7 +205,11 @@ describe("enforceMaxPathLength with template-derived extension (transcripts)", ( describe("getPlatformFilenameLimit", () => { const original = { ...Platform }; const setPlatform = (flags: Partial<typeof Platform>) => - Object.assign(Platform, { isWin: false, isMacOS: false, isLinux: false, isIosApp: false, isAndroidApp: false }, flags); + Object.assign( + Platform, + { isWin: false, isMacOS: false, isLinux: false, isIosApp: false, isAndroidApp: false }, + flags, + ); afterEach(() => { Object.assign(Platform, original); diff --git a/src/utility/enforceMaxPathLength.ts b/src/utility/enforceMaxPathLength.ts index 90151b20..75e20abe 100644 --- a/src/utility/enforceMaxPathLength.ts +++ b/src/utility/enforceMaxPathLength.ts @@ -57,11 +57,7 @@ export function getPlatformFilenameLimit(): FilenameLimit { * split into a lone, malformed half. Iterating with `for...of` yields whole code * points; an astral character is kept only when both of its UTF-16 units fit. */ -function truncateToLimit( - value: string, - max: number, - mode: FilenameLimit["mode"], -): string { +function truncateToLimit(value: string, max: number, mode: FilenameLimit["mode"]): string { if (measure(value, mode) <= max) { return value; } @@ -99,24 +95,17 @@ function capFolderName(segment: string, limit: FilenameLimit): string { return trimSegmentEdges(capped) || FALLBACK_BASENAME; } -function capFileName( - segment: string, - extension: string, - limit: FilenameLimit, -): string { +function capFileName(segment: string, extension: string, limit: FilenameLimit): string { // The path is built by addExtension, so the final segment normally ends with // the known extension. Split it off (when present), cap only the base name, // then always reattach the extension so the file stays a Markdown note even // after truncation or when a caller passed a path without the suffix. const hasExtension = segment.toLowerCase().endsWith(extension.toLowerCase()); - const base = hasExtension - ? segment.slice(0, segment.length - extension.length) - : segment; + const base = hasExtension ? segment.slice(0, segment.length - extension.length) : segment; const budget = Math.max(1, limit.max - measure(extension, limit.mode)); const cappedBase = - trimSegmentEdges(truncateToLimit(base, budget, limit.mode)) || - FALLBACK_BASENAME; + trimSegmentEdges(truncateToLimit(base, budget, limit.mode)) || FALLBACK_BASENAME; return `${cappedBase}${extension}`; } diff --git a/src/utility/ensureFolderExists.test.ts b/src/utility/ensureFolderExists.test.ts index d7bf656f..8b8714dc 100644 --- a/src/utility/ensureFolderExists.test.ts +++ b/src/utility/ensureFolderExists.test.ts @@ -19,8 +19,7 @@ function setupApp(existing: string[] = []) { plugin.set({ app: { vault: { - getAbstractFileByPath: (path: string) => - present.has(path) ? { path } : null, + getAbstractFileByPath: (path: string) => (present.has(path) ? { path } : null), createFolder, }, }, @@ -39,19 +38,13 @@ describe("ensureFolderExists", () => { await ensureFolderExists("podcast/My Show/Season 1"); - expect(created).toEqual([ - "podcast", - "podcast/My Show", - "podcast/My Show/Season 1", - ]); + expect(created).toEqual(["podcast", "podcast/My Show", "podcast/My Show/Season 1"]); }); it("does not recreate existing intermediate folders", async () => { const { created, createFolder } = setupApp(["podcast", "podcast/My Show"]); - await expect( - ensureFolderExists("podcast/My Show/Season 1"), - ).resolves.toBeUndefined(); + await expect(ensureFolderExists("podcast/My Show/Season 1")).resolves.toBeUndefined(); expect(created).toEqual(["podcast/My Show/Season 1"]); expect(createFolder).toHaveBeenCalledTimes(1); @@ -125,8 +118,6 @@ describe("ensureFolderExists", () => { }, } as never); - await expect(ensureFolderExists("Podcasts/My Show")).rejects.toThrow( - "EACCES", - ); + await expect(ensureFolderExists("Podcasts/My Show")).rejects.toThrow("EACCES"); }); }); diff --git a/src/utility/ensureFolderExists.ts b/src/utility/ensureFolderExists.ts index e3671f10..33ebe990 100644 --- a/src/utility/ensureFolderExists.ts +++ b/src/utility/ensureFolderExists.ts @@ -37,8 +37,7 @@ export async function ensureFolderExists( try { await vault.createFolder(current); } catch (error) { - const alreadyExists = - error instanceof Error && /already exists/i.test(error.message); + const alreadyExists = error instanceof Error && /already exists/i.test(error.message); // Re-check after a failed create: a case-insensitive filesystem or a // concurrent create can leave the folder present even though the // pre-check missed it. Only a genuine failure (still absent and not an diff --git a/src/utility/episodeKey.test.ts b/src/utility/episodeKey.test.ts index 0fdb7f90..0b421891 100644 --- a/src/utility/episodeKey.test.ts +++ b/src/utility/episodeKey.test.ts @@ -16,9 +16,7 @@ function ep(title: string, podcastName?: string): Episode { describe("getEpisodeKey", () => { it("keeps the plain podcastName::title format for ordinary episodes (backward compatible)", () => { - expect(getEpisodeKey(ep("Episode 1", "My Podcast"))).toBe( - "My Podcast::Episode 1", - ); + expect(getEpisodeKey(ep("Episode 1", "My Podcast"))).toBe("My Podcast::Episode 1"); }); it("keeps the plain format when names/titles carry single colons", () => { diff --git a/src/utility/episodeListEntry.test.ts b/src/utility/episodeListEntry.test.ts index 27a7a7fa..96f093ca 100644 --- a/src/utility/episodeListEntry.test.ts +++ b/src/utility/episodeListEntry.test.ts @@ -4,11 +4,7 @@ import type { Episode } from "src/types/Episode"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; import { buildPlayedEpisodeListEntries } from "./episodeListEntry"; -function episode( - title: string, - podcastName: string, - episodeDate: string, -): Episode { +function episode(title: string, podcastName: string, episodeDate: string): Episode { return { title, podcastName, @@ -20,11 +16,7 @@ function episode( }; } -function played( - title: string, - podcastName: string, - finished: boolean = true, -): PlayedEpisode { +function played(title: string, podcastName: string, finished: boolean = true): PlayedEpisode { return { title, podcastName, @@ -36,11 +28,7 @@ function played( describe("episodeListEntry", () => { test("resolves finished played records against available episode sources", () => { - const resolvedEpisode = episode( - "Resolved", - "Design Podcast", - "2024-02-01T00:00:00.000Z", - ); + const resolvedEpisode = episode("Resolved", "Design Podcast", "2024-02-01T00:00:00.000Z"); const entries = buildPlayedEpisodeListEntries( { @@ -77,16 +65,8 @@ describe("episodeListEntry", () => { }); test("sorts available played episodes by publish date and unavailable records last", () => { - const olderEpisode = episode( - "Older", - "Design Podcast", - "2024-01-01T00:00:00.000Z", - ); - const newerEpisode = episode( - "Newer", - "Design Podcast", - "2024-03-01T00:00:00.000Z", - ); + const olderEpisode = episode("Older", "Design Podcast", "2024-01-01T00:00:00.000Z"); + const newerEpisode = episode("Newer", "Design Podcast", "2024-03-01T00:00:00.000Z"); const entries = buildPlayedEpisodeListEntries( { @@ -97,10 +77,6 @@ describe("episodeListEntry", () => { [[olderEpisode, newerEpisode]], ); - expect(entries.map((entry) => entry.episode.title)).toEqual([ - "Newer", - "Older", - "Missing", - ]); + expect(entries.map((entry) => entry.episode.title)).toEqual(["Newer", "Older", "Missing"]); }); }); diff --git a/src/utility/episodeListEntry.ts b/src/utility/episodeListEntry.ts index 006609a8..8f6edca4 100644 --- a/src/utility/episodeListEntry.ts +++ b/src/utility/episodeListEntry.ts @@ -25,9 +25,7 @@ export function createEpisodeListEntry(episode: Episode): EpisodeListEntry { }; } -export function createEpisodeListEntries( - episodes: Episode[], -): EpisodeListEntry[] { +export function createEpisodeListEntries(episodes: Episode[]): EpisodeListEntry[] { return episodes.map(createEpisodeListEntry); } @@ -38,9 +36,7 @@ export function buildPlayedEpisodeListEntries( const episodeLookup = buildEpisodeLookup(episodeSources.flat()); return getFinishedPlayedEpisodeRecords(playedEpisodes) - .map(({ key, episode }) => - createPlayedEpisodeListEntry(key, episode, episodeLookup), - ) + .map(({ key, episode }) => createPlayedEpisodeListEntry(key, episode, episodeLookup)) .sort(comparePlayedEpisodeEntries); } @@ -74,11 +70,7 @@ function resolvePlayedEpisode( playedEpisode: PlayedEpisode, episodeLookup: Map<string, Episode>, ): Episode | undefined { - const lookupKeys = [ - key, - getPlayedEpisodeRecordKey(playedEpisode), - playedEpisode.title, - ]; + const lookupKeys = [key, getPlayedEpisodeRecordKey(playedEpisode), playedEpisode.title]; for (const lookupKey of lookupKeys) { const episode = episodeLookup.get(lookupKey); @@ -121,10 +113,7 @@ export function createPlayedEpisodePlaceholder( }; } -function comparePlayedEpisodeEntries( - a: PlayedEpisodeListEntry, - b: PlayedEpisodeListEntry, -): number { +function comparePlayedEpisodeEntries(a: PlayedEpisodeListEntry, b: PlayedEpisodeListEntry): number { if (a.isAvailable !== b.isAvailable) { return a.isAvailable ? -1 : 1; } diff --git a/src/utility/episodeStatus.test.ts b/src/utility/episodeStatus.test.ts index cf968976..a1ab26a0 100644 --- a/src/utility/episodeStatus.test.ts +++ b/src/utility/episodeStatus.test.ts @@ -18,10 +18,7 @@ const episode: Episode = { podcastName: "Design Podcast", }; -function playedEpisode( - podcastName: string, - finished: boolean, -): PlayedEpisode { +function playedEpisode(podcastName: string, finished: boolean): PlayedEpisode { return { title: episode.title, podcastName, @@ -87,9 +84,6 @@ describe("episodeStatus", () => { "Design Podcast::Shared title", ); - expect(aliases).toEqual([ - episode.title, - "Design Podcast::Shared title", - ]); + expect(aliases).toEqual([episode.title, "Design Podcast::Shared title"]); }); }); diff --git a/src/utility/episodeStatus.ts b/src/utility/episodeStatus.ts index c8d45c60..fdfcc9bd 100644 --- a/src/utility/episodeStatus.ts +++ b/src/utility/episodeStatus.ts @@ -53,11 +53,7 @@ export function getPlayedEpisodeAliasKeys( const targetKey = getPlayedEpisodeRecordKey(episode); for (const [key, playedEpisode] of Object.entries(playedEpisodes)) { - if ( - key === sourceKey || - key === targetKey || - isSamePlayedEpisode(playedEpisode, episode) - ) { + if (key === sourceKey || key === targetKey || isSamePlayedEpisode(playedEpisode, episode)) { keys.add(key); } } diff --git a/src/utility/extractStylesFromObj.ts b/src/utility/extractStylesFromObj.ts index 3eee4e48..45bc183c 100644 --- a/src/utility/extractStylesFromObj.ts +++ b/src/utility/extractStylesFromObj.ts @@ -1,7 +1,7 @@ import type { CSSObject } from "src/types/CSSObject"; export default function extractStylesFromObj(obj: CSSObject): string { - return Object.entries(obj) - .map(([key, value]) => `${key}: ${value}`) - .join('; ') + return Object.entries(obj) + .map(([key, value]) => `${key}: ${value}`) + .join("; "); } diff --git a/src/utility/fetchChapters.test.ts b/src/utility/fetchChapters.test.ts index 0fed5678..f64f1286 100644 --- a/src/utility/fetchChapters.test.ts +++ b/src/utility/fetchChapters.test.ts @@ -27,9 +27,7 @@ describe("fetchChapters", () => { }), }); - await expect( - fetchChapters("https://example.com/chapters.json"), - ).resolves.toEqual([ + await expect(fetchChapters("https://example.com/chapters.json")).resolves.toEqual([ { startTime: 0, title: "Intro" }, { startTime: 35, title: "" }, { startTime: 65, title: "Deep Dive" }, @@ -46,8 +44,6 @@ describe("fetchChapters", () => { text: " ".repeat(1_000_001), }); - await expect( - fetchChapters("https://example.com/chapters.json"), - ).resolves.toEqual([]); + await expect(fetchChapters("https://example.com/chapters.json")).resolves.toEqual([]); }); }); diff --git a/src/utility/findPlayedEpisodes.test.ts b/src/utility/findPlayedEpisodes.test.ts index d361e647..3b99c828 100644 --- a/src/utility/findPlayedEpisodes.test.ts +++ b/src/utility/findPlayedEpisodes.test.ts @@ -64,9 +64,7 @@ describe("findPlayedEpisodesInFeeds", () => { episode("Ep A", "Pod A"), episode("Ep Z", "Pod A"), ]); - feedEpisodes.set("https://proto.example/feed", [ - episode("Ep P", "__proto__"), - ]); + feedEpisodes.set("https://proto.example/feed", [episode("Ep P", "__proto__")]); const played = [ playedEp("Ep A", "Pod A"), diff --git a/src/utility/findPlayedEpisodes.ts b/src/utility/findPlayedEpisodes.ts index fe3d4ab7..ae3443f1 100644 --- a/src/utility/findPlayedEpisodes.ts +++ b/src/utility/findPlayedEpisodes.ts @@ -1,44 +1,44 @@ import FeedParser from "src/parser/feedParser"; import type { Episode } from "src/types/Episode"; -import type { PlayedEpisode } from "src/types/PlayedEpisode" +import type { PlayedEpisode } from "src/types/PlayedEpisode"; import type { PodcastFeed } from "src/types/PodcastFeed"; export default async function findPlayedEpisodesInFeeds( - playedEpisodes: PlayedEpisode[], - feeds: PodcastFeed[], + playedEpisodes: PlayedEpisode[], + feeds: PodcastFeed[], ): Promise<Episode[]> { - // Group by podcast name with a Map, not a plain object. The name comes - // verbatim from a feed's `<title>`, so a crafted value like "__proto__" or - // "constructor" would resolve `acc[name]` to an inherited prototype member - // (truthy, no `.push`) and throw, rejecting this Promise. A Map keys on the - // string itself, so any name is handled safely. - const episodesByPodcast = new Map<string, PlayedEpisode[]>(); - for (const episode of playedEpisodes) { - const episodes = episodesByPodcast.get(episode.podcastName); - if (episodes) { - episodes.push(episode); - } else { - episodesByPodcast.set(episode.podcastName, [episode]); - } - } + // Group by podcast name with a Map, not a plain object. The name comes + // verbatim from a feed's `<title>`, so a crafted value like "__proto__" or + // "constructor" would resolve `acc[name]` to an inherited prototype member + // (truthy, no `.push`) and throw, rejecting this Promise. A Map keys on the + // string itself, so any name is handled safely. + const episodesByPodcast = new Map<string, PlayedEpisode[]>(); + for (const episode of playedEpisodes) { + const episodes = episodesByPodcast.get(episode.podcastName); + if (episodes) { + episodes.push(episode); + } else { + episodesByPodcast.set(episode.podcastName, [episode]); + } + } - const playedEpisodesInFeeds: Episode[] = []; + const playedEpisodesInFeeds: Episode[] = []; - for (const [podcastName, episodes] of episodesByPodcast) { - const feed = feeds.find(feed => feed.title === podcastName); - if (!feed) continue; + for (const [podcastName, episodes] of episodesByPodcast) { + const feed = feeds.find((feed) => feed.title === podcastName); + if (!feed) continue; - const parser = new FeedParser(feed); - const episodesInFeed = await parser.getEpisodes(feed.url); + const parser = new FeedParser(feed); + const episodesInFeed = await parser.getEpisodes(feed.url); - for (const episode of episodes) { - const episodeInFeed = episodesInFeed.find(e => e.title === episode.title); + for (const episode of episodes) { + const episodeInFeed = episodesInFeed.find((e) => e.title === episode.title); - if (episodeInFeed) { - playedEpisodesInFeeds.push(episodeInFeed); - } - } - } + if (episodeInFeed) { + playedEpisodesInFeeds.push(episodeInFeed); + } + } + } - return playedEpisodesInFeeds; + return playedEpisodesInFeeds; } diff --git a/src/utility/formatDate.test.ts b/src/utility/formatDate.test.ts index fc17305f..bacd6e7d 100644 --- a/src/utility/formatDate.test.ts +++ b/src/utility/formatDate.test.ts @@ -1,508 +1,576 @@ -import { describe, it, expect } from 'vitest'; -import { formatDate } from './formatDate'; - -describe('formatDate', () => { - // Fixed date for consistent testing: Friday, March 1, 2024, 10:05:03 AM - const testDate = new Date('2024-03-01T10:05:03'); - - // Date with PM time: Saturday, November 23, 2024, 2:30:45 PM - const pmDate = new Date('2024-11-23T14:30:45'); - - // Midnight edge case - const midnightDate = new Date('2024-01-01T00:00:00'); - - // Noon edge case - const noonDate = new Date('2024-01-01T12:00:00'); - - describe('year tokens', () => { - it('formats YYYY as 4-digit year', () => { - expect(formatDate(testDate, 'YYYY')).toBe('2024'); - }); - - it('formats YY as 2-digit year', () => { - expect(formatDate(testDate, 'YY')).toBe('24'); - }); - - it('handles year at turn of century', () => { - const y2k = new Date('2000-06-15T12:00:00'); - expect(formatDate(y2k, 'YY')).toBe('00'); - expect(formatDate(y2k, 'YYYY')).toBe('2000'); - }); - }); - - describe('month tokens', () => { - it('formats MMMM as full month name', () => { - expect(formatDate(testDate, 'MMMM')).toBe('March'); - }); - - it('formats MMM as abbreviated month name', () => { - expect(formatDate(testDate, 'MMM')).toBe('Mar'); - }); - - it('formats MM as zero-padded month number', () => { - expect(formatDate(testDate, 'MM')).toBe('03'); - }); - - it('formats M as month number without padding', () => { - expect(formatDate(testDate, 'M')).toBe('3'); - }); - - it('formats all 12 months correctly with MMMM', () => { - const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - monthNames.forEach((name, index) => { - const date = new Date(2024, index, 15, 10, 30, 0); - expect(formatDate(date, 'MMMM')).toBe(name); - }); - }); - - it('formats all 12 months correctly with MMM', () => { - const monthNames = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' - ]; - monthNames.forEach((name, index) => { - const date = new Date(2024, index, 15, 10, 30, 0); - expect(formatDate(date, 'MMM')).toBe(name); - }); - }); - }); - - describe('day tokens', () => { - it('formats DD as zero-padded day', () => { - expect(formatDate(testDate, 'DD')).toBe('01'); - }); - - it('formats D as day without padding', () => { - expect(formatDate(testDate, 'D')).toBe('1'); - }); - - it('formats Do with ordinal suffix', () => { - expect(formatDate(testDate, 'Do')).toBe('1st'); - expect(formatDate(new Date('2024-03-02T10:00:00'), 'Do')).toBe('2nd'); - expect(formatDate(new Date('2024-03-03T10:00:00'), 'Do')).toBe('3rd'); - expect(formatDate(new Date('2024-03-04T10:00:00'), 'Do')).toBe('4th'); - expect(formatDate(new Date('2024-03-11T10:00:00'), 'Do')).toBe('11th'); - expect(formatDate(new Date('2024-03-12T10:00:00'), 'Do')).toBe('12th'); - expect(formatDate(new Date('2024-03-13T10:00:00'), 'Do')).toBe('13th'); - expect(formatDate(new Date('2024-03-21T10:00:00'), 'Do')).toBe('21st'); - expect(formatDate(new Date('2024-03-22T10:00:00'), 'Do')).toBe('22nd'); - expect(formatDate(new Date('2024-03-23T10:00:00'), 'Do')).toBe('23rd'); - expect(formatDate(new Date('2024-03-31T10:00:00'), 'Do')).toBe('31st'); - }); - }); - - describe('weekday tokens', () => { - it('formats dddd as full weekday name', () => { - expect(formatDate(testDate, 'dddd')).toBe('Friday'); - }); - - it('formats ddd as abbreviated weekday name', () => { - expect(formatDate(testDate, 'ddd')).toBe('Fri'); - }); - - it('formats all 7 weekdays correctly with dddd', () => { - const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - // 2024-03-03 is a Sunday - weekdays.forEach((name, index) => { - const date = new Date(2024, 2, 3 + index, 10, 0, 0); - expect(formatDate(date, 'dddd')).toBe(name); - }); - }); - - it('formats all 7 weekdays correctly with ddd', () => { - const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - weekdays.forEach((name, index) => { - const date = new Date(2024, 2, 3 + index, 10, 0, 0); - expect(formatDate(date, 'ddd')).toBe(name); - }); - }); - }); - - describe('hour tokens', () => { - it('formats HH as zero-padded 24-hour', () => { - expect(formatDate(testDate, 'HH')).toBe('10'); - expect(formatDate(pmDate, 'HH')).toBe('14'); - expect(formatDate(midnightDate, 'HH')).toBe('00'); - }); - - it('formats H as 24-hour without padding', () => { - expect(formatDate(testDate, 'H')).toBe('10'); - expect(formatDate(pmDate, 'H')).toBe('14'); - expect(formatDate(midnightDate, 'H')).toBe('0'); - }); - - it('formats hh as zero-padded 12-hour', () => { - expect(formatDate(testDate, 'hh')).toBe('10'); - expect(formatDate(pmDate, 'hh')).toBe('02'); - expect(formatDate(midnightDate, 'hh')).toBe('12'); - expect(formatDate(noonDate, 'hh')).toBe('12'); - }); - - it('formats h as 12-hour without padding', () => { - expect(formatDate(testDate, 'h')).toBe('10'); - expect(formatDate(pmDate, 'h')).toBe('2'); - expect(formatDate(midnightDate, 'h')).toBe('12'); - expect(formatDate(noonDate, 'h')).toBe('12'); - }); - }); - - describe('minute tokens', () => { - it('formats mm as zero-padded minutes', () => { - expect(formatDate(testDate, 'mm')).toBe('05'); - expect(formatDate(pmDate, 'mm')).toBe('30'); - }); - - it('formats m as minutes without padding', () => { - expect(formatDate(testDate, 'm')).toBe('5'); - expect(formatDate(pmDate, 'm')).toBe('30'); - }); - }); - - describe('second tokens', () => { - it('formats ss as zero-padded seconds', () => { - expect(formatDate(testDate, 'ss')).toBe('03'); - expect(formatDate(pmDate, 'ss')).toBe('45'); - }); - - it('formats s as seconds without padding', () => { - expect(formatDate(testDate, 's')).toBe('3'); - expect(formatDate(pmDate, 's')).toBe('45'); - }); - }); - - describe('AM/PM tokens', () => { - it('formats A as uppercase AM/PM', () => { - expect(formatDate(testDate, 'A')).toBe('AM'); - expect(formatDate(pmDate, 'A')).toBe('PM'); - expect(formatDate(midnightDate, 'A')).toBe('AM'); - expect(formatDate(noonDate, 'A')).toBe('PM'); - }); - - it('formats a as lowercase am/pm', () => { - expect(formatDate(testDate, 'a')).toBe('am'); - expect(formatDate(pmDate, 'a')).toBe('pm'); - }); - }); - - describe('sequential replacement corruption bug', () => { - // This is the critical bug that was fixed - month/weekday names - // containing letters like 'h', 'm', 's' were being corrupted - // by subsequent time replacements - - it('does NOT corrupt "March" with hour replacement (the original bug)', () => { - // March contains 'h' which should NOT be replaced by hour - const marchDate = new Date('2024-03-01T10:00:00'); - expect(formatDate(marchDate, 'MMMM')).toBe('March'); - expect(formatDate(marchDate, 'MMMM')).not.toContain('10'); - }); - - it('does NOT corrupt "March" at any hour', () => { - for (let hour = 0; hour < 24; hour++) { - const date = new Date(2024, 2, 1, hour, 0, 0); - const result = formatDate(date, 'MMMM'); - expect(result).toBe('March'); - } - }); - - it('does NOT corrupt month names containing "h"', () => { - // March (h) - expect(formatDate(new Date('2024-03-15T14:30:00'), 'MMMM')).toBe('March'); - }); - - it('does NOT corrupt month names containing "m"', () => { - // September, November, December contain 'm' - expect(formatDate(new Date('2024-09-15T10:45:00'), 'MMMM')).toBe('September'); - expect(formatDate(new Date('2024-11-15T10:45:00'), 'MMMM')).toBe('November'); - expect(formatDate(new Date('2024-12-15T10:45:00'), 'MMMM')).toBe('December'); - }); - - it('does NOT corrupt month names containing "s"', () => { - // August contains 's' - expect(formatDate(new Date('2024-08-15T10:30:45'), 'MMMM')).toBe('August'); - }); - - it('does NOT corrupt abbreviated months', () => { - expect(formatDate(new Date('2024-03-15T10:00:00'), 'MMM')).toBe('Mar'); - expect(formatDate(new Date('2024-09-15T10:00:00'), 'MMM')).toBe('Sep'); - expect(formatDate(new Date('2024-08-15T10:00:00'), 'MMM')).toBe('Aug'); - }); - - it('does NOT corrupt weekday names containing "h"', () => { - // Thursday contains 'h' - const thursday = new Date('2024-03-07T10:00:00'); // Thursday - expect(formatDate(thursday, 'dddd')).toBe('Thursday'); - }); - - it('does NOT corrupt weekday names containing "m"', () => { - // No weekday contains 'm' but test anyway for safety - const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - weekdays.forEach((name, index) => { - const date = new Date(2024, 2, 3 + index, 10, 30, 0); - expect(formatDate(date, 'dddd')).toBe(name); - }); - }); - - it('does NOT corrupt weekday names containing "s"', () => { - // Tuesday, Wednesday, Thursday, Saturday contain 's' - expect(formatDate(new Date('2024-03-05T10:30:45'), 'dddd')).toBe('Tuesday'); - expect(formatDate(new Date('2024-03-06T10:30:45'), 'dddd')).toBe('Wednesday'); - expect(formatDate(new Date('2024-03-07T10:30:45'), 'dddd')).toBe('Thursday'); - expect(formatDate(new Date('2024-03-09T10:30:45'), 'dddd')).toBe('Saturday'); - }); - - it('does NOT corrupt "Monday" (contains "M" which is a month token)', () => { - const monday = new Date('2024-03-04T10:00:00'); - expect(formatDate(monday, 'dddd')).toBe('Monday'); - }); - - it('does NOT corrupt "Sunday" (contains "D" which is a day token)', () => { - const sunday = new Date('2024-03-03T10:00:00'); - expect(formatDate(sunday, 'dddd')).toBe('Sunday'); - }); - - it('does NOT corrupt ordinal suffixes', () => { - // 'st', 'nd', 'rd', 'th' contain 's' and 'h' - expect(formatDate(new Date('2024-03-01T10:30:45'), 'Do')).toBe('1st'); - expect(formatDate(new Date('2024-03-02T10:30:45'), 'Do')).toBe('2nd'); - expect(formatDate(new Date('2024-03-03T10:30:45'), 'Do')).toBe('3rd'); - expect(formatDate(new Date('2024-03-04T10:30:45'), 'Do')).toBe('4th'); - }); - - it('does NOT corrupt AM/PM markers', () => { - // 'AM' contains 'M', 'PM' contains 'M' - expect(formatDate(new Date('2024-03-01T09:00:00'), 'A')).toBe('AM'); - expect(formatDate(new Date('2024-03-01T15:00:00'), 'A')).toBe('PM'); - expect(formatDate(new Date('2024-03-01T09:00:00'), 'a')).toBe('am'); - expect(formatDate(new Date('2024-03-01T15:00:00'), 'a')).toBe('pm'); - }); - }); - - describe('complex format strings', () => { - it('formats full date and time correctly', () => { - expect(formatDate(testDate, 'YYYY-MM-DD HH:mm:ss')).toBe('2024-03-01 10:05:03'); - }); - - it('formats human-readable date', () => { - expect(formatDate(testDate, 'MMMM Do, YYYY')).toBe('March 1st, 2024'); - }); - - it('formats with weekday', () => { - expect(formatDate(testDate, 'dddd, MMMM Do, YYYY')).toBe('Friday, March 1st, 2024'); - }); - - it('formats 12-hour time with AM/PM', () => { - expect(formatDate(testDate, 'h:mm A')).toBe('10:05 AM'); - expect(formatDate(pmDate, 'h:mm A')).toBe('2:30 PM'); - }); - - it('formats ISO-like date', () => { - expect(formatDate(testDate, 'YYYY-MM-DDTHH:mm:ss')).toBe('2024-03-01T10:05:03'); - }); - - it('formats US-style date', () => { - expect(formatDate(pmDate, 'MM/DD/YYYY')).toBe('11/23/2024'); - }); - - it('formats European-style date', () => { - expect(formatDate(pmDate, 'DD/MM/YYYY')).toBe('23/11/2024'); - }); - - it('handles format with all token types combined', () => { - const result = formatDate(testDate, 'dddd, MMMM Do YYYY, h:mm:ss a'); - expect(result).toBe('Friday, March 1st 2024, 10:05:03 am'); - }); - - it('handles format with repeated tokens', () => { - expect(formatDate(testDate, 'YYYY YYYY YYYY')).toBe('2024 2024 2024'); - expect(formatDate(testDate, 'MMMM MMMM')).toBe('March March'); - }); - - it('preserves literal text in format string using escape syntax', () => { - // In Moment.js style, literal text containing token characters must be escaped with [...] - expect(formatDate(testDate, '[Year]: YYYY')).toBe('Year: 2024'); - expect(formatDate(testDate, '[Today is] dddd')).toBe('Today is Friday'); - expect(formatDate(testDate, '[The date is] MMMM Do')).toBe('The date is March 1st'); - expect(formatDate(testDate, 'YYYY-MM-DD[T]HH:mm:ss')).toBe('2024-03-01T10:05:03'); - }); - }); - - describe('edge cases', () => { - it('handles empty format string', () => { - expect(formatDate(testDate, '')).toBe(''); - }); - - it('handles format string with no tokens (escaped)', () => { - // Literal text with token characters must be escaped - expect(formatDate(testDate, '[Hello World]')).toBe('Hello World'); - expect(formatDate(testDate, '---')).toBe('---'); - expect(formatDate(testDate, '/')).toBe('/'); - }); - - it('handles single-digit values correctly', () => { - const date = new Date('2024-01-05T03:07:09'); - expect(formatDate(date, 'M')).toBe('1'); - expect(formatDate(date, 'D')).toBe('5'); - expect(formatDate(date, 'H')).toBe('3'); - expect(formatDate(date, 'm')).toBe('7'); - expect(formatDate(date, 's')).toBe('9'); - }); - - it('handles double-digit values correctly', () => { - const date = new Date('2024-12-25T23:45:59'); - expect(formatDate(date, 'MM')).toBe('12'); - expect(formatDate(date, 'DD')).toBe('25'); - expect(formatDate(date, 'HH')).toBe('23'); - expect(formatDate(date, 'mm')).toBe('45'); - expect(formatDate(date, 'ss')).toBe('59'); - }); - - it('handles year boundaries', () => { - const newYearsEve = new Date('2024-12-31T23:59:59'); - const newYearsDay = new Date('2025-01-01T00:00:00'); - - expect(formatDate(newYearsEve, 'YYYY-MM-DD HH:mm:ss')).toBe('2024-12-31 23:59:59'); - expect(formatDate(newYearsDay, 'YYYY-MM-DD HH:mm:ss')).toBe('2025-01-01 00:00:00'); - }); - - it('handles leap year date', () => { - const leapDay = new Date('2024-02-29T12:00:00'); - expect(formatDate(leapDay, 'MMMM D, YYYY')).toBe('February 29, 2024'); - }); - }); - - describe('stress tests for replacement corruption', () => { - it('survives all month/hour combinations', () => { - const months = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - - for (let month = 0; month < 12; month++) { - for (let hour = 0; hour < 24; hour++) { - const date = new Date(2024, month, 15, hour, 30, 45); - const result = formatDate(date, 'MMMM'); - expect(result).toBe(months[month]); - } - } - }); - - it('survives all weekday/minute combinations', () => { - const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - - for (let dayOffset = 0; dayOffset < 7; dayOffset++) { - for (let minute = 0; minute < 60; minute += 10) { - const date = new Date(2024, 2, 3 + dayOffset, 10, minute, 0); - const result = formatDate(date, 'dddd'); - expect(result).toBe(weekdays[dayOffset]); - } - } - }); - - it('survives all ordinal/second combinations', () => { - for (let day = 1; day <= 31; day++) { - for (let second = 0; second < 60; second += 15) { - const date = new Date(2024, 0, day, 10, 30, second); - const result = formatDate(date, 'Do'); - expect(result).toMatch(/^\d+(st|nd|rd|th)$/); - expect(result.startsWith(day.toString())).toBe(true); - } - } - }); - - it('handles worst-case format with all potentially conflicting tokens', () => { - // This format uses every token type that could potentially corrupt each other - // Note: 'at' must be escaped as '[at]' because 'a' is a token - const format = 'dddd, MMMM Do, YYYY [at] h:mm:ss a (HH:mm:ss A)'; - - // Test across various dates and times - const testCases = [ - { date: new Date('2024-03-01T10:05:03'), expected: 'Friday, March 1st, 2024 at 10:05:03 am (10:05:03 AM)' }, - { date: new Date('2024-08-15T14:30:45'), expected: 'Thursday, August 15th, 2024 at 2:30:45 pm (14:30:45 PM)' }, - { date: new Date('2024-09-22T00:00:00'), expected: 'Sunday, September 22nd, 2024 at 12:00:00 am (00:00:00 AM)' }, - { date: new Date('2024-11-07T23:59:59'), expected: 'Thursday, November 7th, 2024 at 11:59:59 pm (23:59:59 PM)' }, - ]; - - testCases.forEach(({ date, expected }) => { - expect(formatDate(date, format)).toBe(expected); - }); - }); - - it('handles pathological format strings', () => { - // Format strings designed to maximize corruption potential - expect(formatDate(testDate, 'MMMMMMMMMhhhhhssss')).toMatch(/^March/); - expect(formatDate(testDate, 'hhhhMMMM')).toContain('March'); - expect(formatDate(testDate, 'ssssdddd')).toContain('Friday'); - }); - }); - - describe('escape syntax [...]', () => { - it('escapes single characters', () => { - expect(formatDate(testDate, '[H]H')).toBe('H10'); - expect(formatDate(testDate, '[m]m')).toBe('m5'); - expect(formatDate(testDate, '[a]')).toBe('a'); - expect(formatDate(testDate, '[M]')).toBe('M'); - }); - - it('escapes words containing token characters', () => { - expect(formatDate(testDate, '[at] h:mm a')).toBe('at 10:05 am'); - expect(formatDate(testDate, '[March] MMMM')).toBe('March March'); - expect(formatDate(testDate, '[Hour]: H')).toBe('Hour: 10'); - }); - - it('handles multiple escape sequences', () => { - expect(formatDate(testDate, '[Date]: YYYY-MM-DD [Time]: HH:mm:ss')).toBe('Date: 2024-03-01 Time: 10:05:03'); - }); - - it('handles empty escape sequences', () => { - expect(formatDate(testDate, '[]YYYY')).toBe('2024'); - expect(formatDate(testDate, 'YYYY[]')).toBe('2024'); - }); - - it('handles escape sequences with special regex characters', () => { - expect(formatDate(testDate, '[.*+?^${}()|]')).toBe('.*+?^${}()|'); - expect(formatDate(testDate, '[\\]')).toBe('\\'); - }); - - it('handles nested brackets (edge case)', () => { - // The first ] closes the escape, subsequent brackets are literal - expect(formatDate(testDate, '[[test]]')).toBe('[test]'); - }); - }); - - describe('token isolation', () => { - // Verify that each token type is properly isolated and doesn't affect others - - it('M token does not affect MMMM in same format', () => { - const result = formatDate(testDate, 'M MMMM'); - expect(result).toBe('3 March'); - }); - - it('D token does not affect dddd in same format', () => { - const result = formatDate(testDate, 'D dddd'); - expect(result).toBe('1 Friday'); - }); - - it('h token does not affect HH in same format', () => { - const result = formatDate(pmDate, 'h HH'); - expect(result).toBe('2 14'); - }); - - it('m token does not affect mm in same format', () => { - const result = formatDate(testDate, 'm mm'); - expect(result).toBe('5 05'); - }); - - it('s token does not affect ss in same format', () => { - const result = formatDate(testDate, 's ss'); - expect(result).toBe('3 03'); - }); - - it('a token does not affect A in same format', () => { - const result = formatDate(testDate, 'a A'); - expect(result).toBe('am AM'); - }); - }); +import { describe, it, expect } from "vitest"; +import { formatDate } from "./formatDate"; + +describe("formatDate", () => { + // Fixed date for consistent testing: Friday, March 1, 2024, 10:05:03 AM + const testDate = new Date("2024-03-01T10:05:03"); + + // Date with PM time: Saturday, November 23, 2024, 2:30:45 PM + const pmDate = new Date("2024-11-23T14:30:45"); + + // Midnight edge case + const midnightDate = new Date("2024-01-01T00:00:00"); + + // Noon edge case + const noonDate = new Date("2024-01-01T12:00:00"); + + describe("year tokens", () => { + it("formats YYYY as 4-digit year", () => { + expect(formatDate(testDate, "YYYY")).toBe("2024"); + }); + + it("formats YY as 2-digit year", () => { + expect(formatDate(testDate, "YY")).toBe("24"); + }); + + it("handles year at turn of century", () => { + const y2k = new Date("2000-06-15T12:00:00"); + expect(formatDate(y2k, "YY")).toBe("00"); + expect(formatDate(y2k, "YYYY")).toBe("2000"); + }); + }); + + describe("month tokens", () => { + it("formats MMMM as full month name", () => { + expect(formatDate(testDate, "MMMM")).toBe("March"); + }); + + it("formats MMM as abbreviated month name", () => { + expect(formatDate(testDate, "MMM")).toBe("Mar"); + }); + + it("formats MM as zero-padded month number", () => { + expect(formatDate(testDate, "MM")).toBe("03"); + }); + + it("formats M as month number without padding", () => { + expect(formatDate(testDate, "M")).toBe("3"); + }); + + it("formats all 12 months correctly with MMMM", () => { + const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + monthNames.forEach((name, index) => { + const date = new Date(2024, index, 15, 10, 30, 0); + expect(formatDate(date, "MMMM")).toBe(name); + }); + }); + + it("formats all 12 months correctly with MMM", () => { + const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + monthNames.forEach((name, index) => { + const date = new Date(2024, index, 15, 10, 30, 0); + expect(formatDate(date, "MMM")).toBe(name); + }); + }); + }); + + describe("day tokens", () => { + it("formats DD as zero-padded day", () => { + expect(formatDate(testDate, "DD")).toBe("01"); + }); + + it("formats D as day without padding", () => { + expect(formatDate(testDate, "D")).toBe("1"); + }); + + it("formats Do with ordinal suffix", () => { + expect(formatDate(testDate, "Do")).toBe("1st"); + expect(formatDate(new Date("2024-03-02T10:00:00"), "Do")).toBe("2nd"); + expect(formatDate(new Date("2024-03-03T10:00:00"), "Do")).toBe("3rd"); + expect(formatDate(new Date("2024-03-04T10:00:00"), "Do")).toBe("4th"); + expect(formatDate(new Date("2024-03-11T10:00:00"), "Do")).toBe("11th"); + expect(formatDate(new Date("2024-03-12T10:00:00"), "Do")).toBe("12th"); + expect(formatDate(new Date("2024-03-13T10:00:00"), "Do")).toBe("13th"); + expect(formatDate(new Date("2024-03-21T10:00:00"), "Do")).toBe("21st"); + expect(formatDate(new Date("2024-03-22T10:00:00"), "Do")).toBe("22nd"); + expect(formatDate(new Date("2024-03-23T10:00:00"), "Do")).toBe("23rd"); + expect(formatDate(new Date("2024-03-31T10:00:00"), "Do")).toBe("31st"); + }); + }); + + describe("weekday tokens", () => { + it("formats dddd as full weekday name", () => { + expect(formatDate(testDate, "dddd")).toBe("Friday"); + }); + + it("formats ddd as abbreviated weekday name", () => { + expect(formatDate(testDate, "ddd")).toBe("Fri"); + }); + + it("formats all 7 weekdays correctly with dddd", () => { + const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + // 2024-03-03 is a Sunday + weekdays.forEach((name, index) => { + const date = new Date(2024, 2, 3 + index, 10, 0, 0); + expect(formatDate(date, "dddd")).toBe(name); + }); + }); + + it("formats all 7 weekdays correctly with ddd", () => { + const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + weekdays.forEach((name, index) => { + const date = new Date(2024, 2, 3 + index, 10, 0, 0); + expect(formatDate(date, "ddd")).toBe(name); + }); + }); + }); + + describe("hour tokens", () => { + it("formats HH as zero-padded 24-hour", () => { + expect(formatDate(testDate, "HH")).toBe("10"); + expect(formatDate(pmDate, "HH")).toBe("14"); + expect(formatDate(midnightDate, "HH")).toBe("00"); + }); + + it("formats H as 24-hour without padding", () => { + expect(formatDate(testDate, "H")).toBe("10"); + expect(formatDate(pmDate, "H")).toBe("14"); + expect(formatDate(midnightDate, "H")).toBe("0"); + }); + + it("formats hh as zero-padded 12-hour", () => { + expect(formatDate(testDate, "hh")).toBe("10"); + expect(formatDate(pmDate, "hh")).toBe("02"); + expect(formatDate(midnightDate, "hh")).toBe("12"); + expect(formatDate(noonDate, "hh")).toBe("12"); + }); + + it("formats h as 12-hour without padding", () => { + expect(formatDate(testDate, "h")).toBe("10"); + expect(formatDate(pmDate, "h")).toBe("2"); + expect(formatDate(midnightDate, "h")).toBe("12"); + expect(formatDate(noonDate, "h")).toBe("12"); + }); + }); + + describe("minute tokens", () => { + it("formats mm as zero-padded minutes", () => { + expect(formatDate(testDate, "mm")).toBe("05"); + expect(formatDate(pmDate, "mm")).toBe("30"); + }); + + it("formats m as minutes without padding", () => { + expect(formatDate(testDate, "m")).toBe("5"); + expect(formatDate(pmDate, "m")).toBe("30"); + }); + }); + + describe("second tokens", () => { + it("formats ss as zero-padded seconds", () => { + expect(formatDate(testDate, "ss")).toBe("03"); + expect(formatDate(pmDate, "ss")).toBe("45"); + }); + + it("formats s as seconds without padding", () => { + expect(formatDate(testDate, "s")).toBe("3"); + expect(formatDate(pmDate, "s")).toBe("45"); + }); + }); + + describe("AM/PM tokens", () => { + it("formats A as uppercase AM/PM", () => { + expect(formatDate(testDate, "A")).toBe("AM"); + expect(formatDate(pmDate, "A")).toBe("PM"); + expect(formatDate(midnightDate, "A")).toBe("AM"); + expect(formatDate(noonDate, "A")).toBe("PM"); + }); + + it("formats a as lowercase am/pm", () => { + expect(formatDate(testDate, "a")).toBe("am"); + expect(formatDate(pmDate, "a")).toBe("pm"); + }); + }); + + describe("sequential replacement corruption bug", () => { + // This is the critical bug that was fixed - month/weekday names + // containing letters like 'h', 'm', 's' were being corrupted + // by subsequent time replacements + + it('does NOT corrupt "March" with hour replacement (the original bug)', () => { + // March contains 'h' which should NOT be replaced by hour + const marchDate = new Date("2024-03-01T10:00:00"); + expect(formatDate(marchDate, "MMMM")).toBe("March"); + expect(formatDate(marchDate, "MMMM")).not.toContain("10"); + }); + + it('does NOT corrupt "March" at any hour', () => { + for (let hour = 0; hour < 24; hour++) { + const date = new Date(2024, 2, 1, hour, 0, 0); + const result = formatDate(date, "MMMM"); + expect(result).toBe("March"); + } + }); + + it('does NOT corrupt month names containing "h"', () => { + // March (h) + expect(formatDate(new Date("2024-03-15T14:30:00"), "MMMM")).toBe("March"); + }); + + it('does NOT corrupt month names containing "m"', () => { + // September, November, December contain 'm' + expect(formatDate(new Date("2024-09-15T10:45:00"), "MMMM")).toBe("September"); + expect(formatDate(new Date("2024-11-15T10:45:00"), "MMMM")).toBe("November"); + expect(formatDate(new Date("2024-12-15T10:45:00"), "MMMM")).toBe("December"); + }); + + it('does NOT corrupt month names containing "s"', () => { + // August contains 's' + expect(formatDate(new Date("2024-08-15T10:30:45"), "MMMM")).toBe("August"); + }); + + it("does NOT corrupt abbreviated months", () => { + expect(formatDate(new Date("2024-03-15T10:00:00"), "MMM")).toBe("Mar"); + expect(formatDate(new Date("2024-09-15T10:00:00"), "MMM")).toBe("Sep"); + expect(formatDate(new Date("2024-08-15T10:00:00"), "MMM")).toBe("Aug"); + }); + + it('does NOT corrupt weekday names containing "h"', () => { + // Thursday contains 'h' + const thursday = new Date("2024-03-07T10:00:00"); // Thursday + expect(formatDate(thursday, "dddd")).toBe("Thursday"); + }); + + it('does NOT corrupt weekday names containing "m"', () => { + // No weekday contains 'm' but test anyway for safety + const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + weekdays.forEach((name, index) => { + const date = new Date(2024, 2, 3 + index, 10, 30, 0); + expect(formatDate(date, "dddd")).toBe(name); + }); + }); + + it('does NOT corrupt weekday names containing "s"', () => { + // Tuesday, Wednesday, Thursday, Saturday contain 's' + expect(formatDate(new Date("2024-03-05T10:30:45"), "dddd")).toBe("Tuesday"); + expect(formatDate(new Date("2024-03-06T10:30:45"), "dddd")).toBe("Wednesday"); + expect(formatDate(new Date("2024-03-07T10:30:45"), "dddd")).toBe("Thursday"); + expect(formatDate(new Date("2024-03-09T10:30:45"), "dddd")).toBe("Saturday"); + }); + + it('does NOT corrupt "Monday" (contains "M" which is a month token)', () => { + const monday = new Date("2024-03-04T10:00:00"); + expect(formatDate(monday, "dddd")).toBe("Monday"); + }); + + it('does NOT corrupt "Sunday" (contains "D" which is a day token)', () => { + const sunday = new Date("2024-03-03T10:00:00"); + expect(formatDate(sunday, "dddd")).toBe("Sunday"); + }); + + it("does NOT corrupt ordinal suffixes", () => { + // 'st', 'nd', 'rd', 'th' contain 's' and 'h' + expect(formatDate(new Date("2024-03-01T10:30:45"), "Do")).toBe("1st"); + expect(formatDate(new Date("2024-03-02T10:30:45"), "Do")).toBe("2nd"); + expect(formatDate(new Date("2024-03-03T10:30:45"), "Do")).toBe("3rd"); + expect(formatDate(new Date("2024-03-04T10:30:45"), "Do")).toBe("4th"); + }); + + it("does NOT corrupt AM/PM markers", () => { + // 'AM' contains 'M', 'PM' contains 'M' + expect(formatDate(new Date("2024-03-01T09:00:00"), "A")).toBe("AM"); + expect(formatDate(new Date("2024-03-01T15:00:00"), "A")).toBe("PM"); + expect(formatDate(new Date("2024-03-01T09:00:00"), "a")).toBe("am"); + expect(formatDate(new Date("2024-03-01T15:00:00"), "a")).toBe("pm"); + }); + }); + + describe("complex format strings", () => { + it("formats full date and time correctly", () => { + expect(formatDate(testDate, "YYYY-MM-DD HH:mm:ss")).toBe("2024-03-01 10:05:03"); + }); + + it("formats human-readable date", () => { + expect(formatDate(testDate, "MMMM Do, YYYY")).toBe("March 1st, 2024"); + }); + + it("formats with weekday", () => { + expect(formatDate(testDate, "dddd, MMMM Do, YYYY")).toBe("Friday, March 1st, 2024"); + }); + + it("formats 12-hour time with AM/PM", () => { + expect(formatDate(testDate, "h:mm A")).toBe("10:05 AM"); + expect(formatDate(pmDate, "h:mm A")).toBe("2:30 PM"); + }); + + it("formats ISO-like date", () => { + expect(formatDate(testDate, "YYYY-MM-DDTHH:mm:ss")).toBe("2024-03-01T10:05:03"); + }); + + it("formats US-style date", () => { + expect(formatDate(pmDate, "MM/DD/YYYY")).toBe("11/23/2024"); + }); + + it("formats European-style date", () => { + expect(formatDate(pmDate, "DD/MM/YYYY")).toBe("23/11/2024"); + }); + + it("handles format with all token types combined", () => { + const result = formatDate(testDate, "dddd, MMMM Do YYYY, h:mm:ss a"); + expect(result).toBe("Friday, March 1st 2024, 10:05:03 am"); + }); + + it("handles format with repeated tokens", () => { + expect(formatDate(testDate, "YYYY YYYY YYYY")).toBe("2024 2024 2024"); + expect(formatDate(testDate, "MMMM MMMM")).toBe("March March"); + }); + + it("preserves literal text in format string using escape syntax", () => { + // In Moment.js style, literal text containing token characters must be escaped with [...] + expect(formatDate(testDate, "[Year]: YYYY")).toBe("Year: 2024"); + expect(formatDate(testDate, "[Today is] dddd")).toBe("Today is Friday"); + expect(formatDate(testDate, "[The date is] MMMM Do")).toBe("The date is March 1st"); + expect(formatDate(testDate, "YYYY-MM-DD[T]HH:mm:ss")).toBe("2024-03-01T10:05:03"); + }); + }); + + describe("edge cases", () => { + it("handles empty format string", () => { + expect(formatDate(testDate, "")).toBe(""); + }); + + it("handles format string with no tokens (escaped)", () => { + // Literal text with token characters must be escaped + expect(formatDate(testDate, "[Hello World]")).toBe("Hello World"); + expect(formatDate(testDate, "---")).toBe("---"); + expect(formatDate(testDate, "/")).toBe("/"); + }); + + it("handles single-digit values correctly", () => { + const date = new Date("2024-01-05T03:07:09"); + expect(formatDate(date, "M")).toBe("1"); + expect(formatDate(date, "D")).toBe("5"); + expect(formatDate(date, "H")).toBe("3"); + expect(formatDate(date, "m")).toBe("7"); + expect(formatDate(date, "s")).toBe("9"); + }); + + it("handles double-digit values correctly", () => { + const date = new Date("2024-12-25T23:45:59"); + expect(formatDate(date, "MM")).toBe("12"); + expect(formatDate(date, "DD")).toBe("25"); + expect(formatDate(date, "HH")).toBe("23"); + expect(formatDate(date, "mm")).toBe("45"); + expect(formatDate(date, "ss")).toBe("59"); + }); + + it("handles year boundaries", () => { + const newYearsEve = new Date("2024-12-31T23:59:59"); + const newYearsDay = new Date("2025-01-01T00:00:00"); + + expect(formatDate(newYearsEve, "YYYY-MM-DD HH:mm:ss")).toBe("2024-12-31 23:59:59"); + expect(formatDate(newYearsDay, "YYYY-MM-DD HH:mm:ss")).toBe("2025-01-01 00:00:00"); + }); + + it("handles leap year date", () => { + const leapDay = new Date("2024-02-29T12:00:00"); + expect(formatDate(leapDay, "MMMM D, YYYY")).toBe("February 29, 2024"); + }); + }); + + describe("stress tests for replacement corruption", () => { + it("survives all month/hour combinations", () => { + const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + for (let month = 0; month < 12; month++) { + for (let hour = 0; hour < 24; hour++) { + const date = new Date(2024, month, 15, hour, 30, 45); + const result = formatDate(date, "MMMM"); + expect(result).toBe(months[month]); + } + } + }); + + it("survives all weekday/minute combinations", () => { + const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + + for (let dayOffset = 0; dayOffset < 7; dayOffset++) { + for (let minute = 0; minute < 60; minute += 10) { + const date = new Date(2024, 2, 3 + dayOffset, 10, minute, 0); + const result = formatDate(date, "dddd"); + expect(result).toBe(weekdays[dayOffset]); + } + } + }); + + it("survives all ordinal/second combinations", () => { + for (let day = 1; day <= 31; day++) { + for (let second = 0; second < 60; second += 15) { + const date = new Date(2024, 0, day, 10, 30, second); + const result = formatDate(date, "Do"); + expect(result).toMatch(/^\d+(st|nd|rd|th)$/); + expect(result.startsWith(day.toString())).toBe(true); + } + } + }); + + it("handles worst-case format with all potentially conflicting tokens", () => { + // This format uses every token type that could potentially corrupt each other + // Note: 'at' must be escaped as '[at]' because 'a' is a token + const format = "dddd, MMMM Do, YYYY [at] h:mm:ss a (HH:mm:ss A)"; + + // Test across various dates and times + const testCases = [ + { + date: new Date("2024-03-01T10:05:03"), + expected: "Friday, March 1st, 2024 at 10:05:03 am (10:05:03 AM)", + }, + { + date: new Date("2024-08-15T14:30:45"), + expected: "Thursday, August 15th, 2024 at 2:30:45 pm (14:30:45 PM)", + }, + { + date: new Date("2024-09-22T00:00:00"), + expected: "Sunday, September 22nd, 2024 at 12:00:00 am (00:00:00 AM)", + }, + { + date: new Date("2024-11-07T23:59:59"), + expected: "Thursday, November 7th, 2024 at 11:59:59 pm (23:59:59 PM)", + }, + ]; + + testCases.forEach(({ date, expected }) => { + expect(formatDate(date, format)).toBe(expected); + }); + }); + + it("handles pathological format strings", () => { + // Format strings designed to maximize corruption potential + expect(formatDate(testDate, "MMMMMMMMMhhhhhssss")).toMatch(/^March/); + expect(formatDate(testDate, "hhhhMMMM")).toContain("March"); + expect(formatDate(testDate, "ssssdddd")).toContain("Friday"); + }); + }); + + describe("escape syntax [...]", () => { + it("escapes single characters", () => { + expect(formatDate(testDate, "[H]H")).toBe("H10"); + expect(formatDate(testDate, "[m]m")).toBe("m5"); + expect(formatDate(testDate, "[a]")).toBe("a"); + expect(formatDate(testDate, "[M]")).toBe("M"); + }); + + it("escapes words containing token characters", () => { + expect(formatDate(testDate, "[at] h:mm a")).toBe("at 10:05 am"); + expect(formatDate(testDate, "[March] MMMM")).toBe("March March"); + expect(formatDate(testDate, "[Hour]: H")).toBe("Hour: 10"); + }); + + it("handles multiple escape sequences", () => { + expect(formatDate(testDate, "[Date]: YYYY-MM-DD [Time]: HH:mm:ss")).toBe( + "Date: 2024-03-01 Time: 10:05:03", + ); + }); + + it("handles empty escape sequences", () => { + expect(formatDate(testDate, "[]YYYY")).toBe("2024"); + expect(formatDate(testDate, "YYYY[]")).toBe("2024"); + }); + + it("handles escape sequences with special regex characters", () => { + expect(formatDate(testDate, "[.*+?^${}()|]")).toBe(".*+?^${}()|"); + expect(formatDate(testDate, "[\\]")).toBe("\\"); + }); + + it("handles nested brackets (edge case)", () => { + // The first ] closes the escape, subsequent brackets are literal + expect(formatDate(testDate, "[[test]]")).toBe("[test]"); + }); + }); + + describe("token isolation", () => { + // Verify that each token type is properly isolated and doesn't affect others + + it("M token does not affect MMMM in same format", () => { + const result = formatDate(testDate, "M MMMM"); + expect(result).toBe("3 March"); + }); + + it("D token does not affect dddd in same format", () => { + const result = formatDate(testDate, "D dddd"); + expect(result).toBe("1 Friday"); + }); + + it("h token does not affect HH in same format", () => { + const result = formatDate(pmDate, "h HH"); + expect(result).toBe("2 14"); + }); + + it("m token does not affect mm in same format", () => { + const result = formatDate(testDate, "m mm"); + expect(result).toBe("5 05"); + }); + + it("s token does not affect ss in same format", () => { + const result = formatDate(testDate, "s ss"); + expect(result).toBe("3 03"); + }); + + it("a token does not affect A in same format", () => { + const result = formatDate(testDate, "a A"); + expect(result).toBe("am AM"); + }); + }); }); diff --git a/src/utility/formatDate.ts b/src/utility/formatDate.ts index 3c664650..5ec3afc1 100644 --- a/src/utility/formatDate.ts +++ b/src/utility/formatDate.ts @@ -12,87 +12,113 @@ * - [text]: literal text (escaped, not parsed as tokens) */ export function formatDate(date: Date, format: string): string { - const year = date.getFullYear(); - const month = date.getMonth(); - const day = date.getDate(); - const weekday = date.getDay(); - const hours = date.getHours(); - const minutes = date.getMinutes(); - const seconds = date.getSeconds(); + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const weekday = date.getDay(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); - const pad = (n: number): string => n.toString().padStart(2, '0'); + const pad = (n: number): string => n.toString().padStart(2, "0"); - const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - const monthNamesShort = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' - ]; - const weekdayNames = [ - 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' - ]; - const weekdayNamesShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + const monthNamesShort = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const weekdayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + const weekdayNamesShort = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const ordinal = (n: number): string => { - const s = ['th', 'st', 'nd', 'rd']; - const v = n % 100; - return n + (s[(v - 20) % 10] || s[v] || s[0]); - }; + const ordinal = (n: number): string => { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); + }; - const hours12 = hours % 12 || 12; - const isPM = hours >= 12; + const hours12 = hours % 12 || 12; + const isPM = hours >= 12; - // Token definitions: order matters (longer tokens first to avoid partial matches) - const tokens: Record<string, string> = { - 'YYYY': year.toString(), - 'YY': year.toString().slice(-2), - 'MMMM': monthNames[month], - 'MMM': monthNamesShort[month], - 'MM': pad(month + 1), - 'Mo': ordinal(month + 1), - 'M': (month + 1).toString(), - 'dddd': weekdayNames[weekday], - 'ddd': weekdayNamesShort[weekday], - 'Do': ordinal(day), - 'DD': pad(day), - 'D': day.toString(), - 'HH': pad(hours), - 'H': hours.toString(), - 'hh': pad(hours12), - 'h': hours12.toString(), - 'mm': pad(minutes), - 'm': minutes.toString(), - 'ss': pad(seconds), - 's': seconds.toString(), - 'A': isPM ? 'PM' : 'AM', - 'a': isPM ? 'pm' : 'am', - }; + // Token definitions: order matters (longer tokens first to avoid partial matches) + const tokens: Record<string, string> = { + YYYY: year.toString(), + YY: year.toString().slice(-2), + MMMM: monthNames[month], + MMM: monthNamesShort[month], + MM: pad(month + 1), + Mo: ordinal(month + 1), + M: (month + 1).toString(), + dddd: weekdayNames[weekday], + ddd: weekdayNamesShort[weekday], + Do: ordinal(day), + DD: pad(day), + D: day.toString(), + HH: pad(hours), + H: hours.toString(), + hh: pad(hours12), + h: hours12.toString(), + mm: pad(minutes), + m: minutes.toString(), + ss: pad(seconds), + s: seconds.toString(), + A: isPM ? "PM" : "AM", + a: isPM ? "pm" : "am", + }; - // Build regex pattern: escaped literals [*], then tokens (longest first), then any char - const tokenPattern = Object.keys(tokens) - .sort((a, b) => b.length - a.length) - .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|'); + // Build regex pattern: escaped literals [*], then tokens (longest first), then any char + const tokenPattern = Object.keys(tokens) + .sort((a, b) => b.length - a.length) + .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); - const regex = new RegExp(`\\[([^\\]]*)]|(${tokenPattern})|.`, 'g'); + const regex = new RegExp(`\\[([^\\]]*)]|(${tokenPattern})|.`, "g"); - let result = ''; - let match: RegExpExecArray | null; + let result = ""; + let match: RegExpExecArray | null; - while ((match = regex.exec(format)) !== null) { - if (match[1] !== undefined) { - // Escaped literal text inside [...] - result += match[1]; - } else if (match[2] !== undefined) { - // Token match - result += tokens[match[2]]; - } else { - // Any other character (literal) - result += match[0]; - } - } + while ((match = regex.exec(format)) !== null) { + if (match[1] !== undefined) { + // Escaped literal text inside [...] + result += match[1]; + } else if (match[2] !== undefined) { + // Token match + result += tokens[match[2]]; + } else { + // Any other character (literal) + result += match[0]; + } + } - return result; + return result; } diff --git a/src/utility/formatEpisodeNumber.ts b/src/utility/formatEpisodeNumber.ts index 5af3588c..33927c85 100644 --- a/src/utility/formatEpisodeNumber.ts +++ b/src/utility/formatEpisodeNumber.ts @@ -4,10 +4,7 @@ * to that width so episode-numbered file names sort correctly; any other format * argument is ignored and the bare number is returned. */ -export function formatEpisodeNumber( - episodeNumber: number | undefined, - pad?: string, -): string { +export function formatEpisodeNumber(episodeNumber: number | undefined, pad?: string): string { if (episodeNumber === undefined) return ""; const value = String(episodeNumber); const width = pad?.trim(); diff --git a/src/utility/formatSeconds.test.ts b/src/utility/formatSeconds.test.ts index 1d6c0acf..d0ce77dd 100644 --- a/src/utility/formatSeconds.test.ts +++ b/src/utility/formatSeconds.test.ts @@ -20,9 +20,7 @@ describe("formatSeconds", () => { }); test("clamps non-finite Infinity to 0", () => { - expect(formatSeconds(Number.POSITIVE_INFINITY, "HH:mm:ss")).toBe( - "00:00:00", - ); + expect(formatSeconds(Number.POSITIVE_INFINITY, "HH:mm:ss")).toBe("00:00:00"); }); test("substitutes each token once, never re-matching inserted digits (TS-09)", () => { diff --git a/src/utility/formatSeconds.ts b/src/utility/formatSeconds.ts index 5e5d3ed1..53474d0d 100644 --- a/src/utility/formatSeconds.ts +++ b/src/utility/formatSeconds.ts @@ -14,43 +14,42 @@ * own escaping (e.g. `\\h` renders a literal "h"). See issue #?? (TS-09). */ export function formatSeconds(totalSeconds: number, format: string): string { - // Clamp non-finite (NaN/Infinity) and negative inputs to 0 so the player never - // renders garbled times like "NaN:NaN:NaN" or "-1:-1:-10". These arise during - // an episode switch, when duration is briefly unknown (NaN) before the new - // audio's metadata loads, or when currentTime momentarily exceeds a shorter - // next episode's duration (issue #94). - if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { - totalSeconds = 0; - } + // Clamp non-finite (NaN/Infinity) and negative inputs to 0 so the player never + // renders garbled times like "NaN:NaN:NaN" or "-1:-1:-10". These arise during + // an episode switch, when duration is briefly unknown (NaN) before the new + // audio's metadata loads, or when currentTime momentarily exceeds a shorter + // next episode's duration (issue #94). + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + totalSeconds = 0; + } - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const secs = Math.floor(totalSeconds % 60); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = Math.floor(totalSeconds % 60); - const hours12 = hours % 12 || 12; - const isPM = hours >= 12; + const hours12 = hours % 12 || 12; + const isPM = hours >= 12; - const pad = (n: number): string => n.toString().padStart(2, '0'); + const pad = (n: number): string => n.toString().padStart(2, "0"); - // Longest tokens first so the alternation prefers HH over H, etc. - const tokens: Record<string, string> = { - HH: pad(hours), - hh: pad(hours12), - mm: pad(minutes), - ss: pad(secs), - H: hours.toString(), - h: hours12.toString(), - m: minutes.toString(), - s: secs.toString(), - A: isPM ? 'PM' : 'AM', - a: isPM ? 'pm' : 'am', - }; + // Longest tokens first so the alternation prefers HH over H, etc. + const tokens: Record<string, string> = { + HH: pad(hours), + hh: pad(hours12), + mm: pad(minutes), + ss: pad(secs), + H: hours.toString(), + h: hours12.toString(), + m: minutes.toString(), + s: secs.toString(), + A: isPM ? "PM" : "AM", + a: isPM ? "pm" : "am", + }; - // `\\(.)` consumes an escaped literal first (emitting the escaped char as-is), - // otherwise the longest matching token is replaced. Everything else (`:`, `-`, - // spaces, unescaped non-token letters) passes through untouched. - return format.replace( - /\\(.)|HH|hh|mm|ss|H|h|m|s|A|a/g, - (match, escaped) => (escaped !== undefined ? escaped : tokens[match]), - ); -} \ No newline at end of file + // `\\(.)` consumes an escaped literal first (emitting the escaped char as-is), + // otherwise the longest matching token is replaced. Everything else (`:`, `-`, + // spaces, unescaped non-token letters) passes through untouched. + return format.replace(/\\(.)|HH|hh|mm|ss|H|h|m|s|A|a/g, (match, escaped) => + escaped !== undefined ? escaped : tokens[match], + ); +} diff --git a/src/utility/getEpisodeTranscriptPath.ts b/src/utility/getEpisodeTranscriptPath.ts index e119e308..288271c2 100644 --- a/src/utility/getEpisodeTranscriptPath.ts +++ b/src/utility/getEpisodeTranscriptPath.ts @@ -1,14 +1,8 @@ import { FilePathTemplateEngine } from "src/TemplateEngine"; import type { Episode } from "src/types/Episode"; -import { - enforceMaxPathLength, - lastSegmentExtension, -} from "src/utility/enforceMaxPathLength"; +import { enforceMaxPathLength, lastSegmentExtension } from "src/utility/enforceMaxPathLength"; -export function getEpisodeTranscriptPath( - episode: Episode, - transcriptPathTemplate: string, -): string { +export function getEpisodeTranscriptPath(episode: Episode, transcriptPathTemplate: string): string { const rendered = FilePathTemplateEngine(transcriptPathTemplate, episode); return enforceMaxPathLength(rendered, lastSegmentExtension(rendered)); } diff --git a/src/utility/getExtensionFromContentType.ts b/src/utility/getExtensionFromContentType.ts index 95fa246b..9cc3a464 100644 --- a/src/utility/getExtensionFromContentType.ts +++ b/src/utility/getExtensionFromContentType.ts @@ -23,9 +23,7 @@ const CONTENT_TYPE_EXTENSION_MAP: Array<{ { pattern: /video\/ogg/i, extension: "ogv" }, ]; -export default function getExtensionFromContentType( - contentType?: string | null, -): string | null { +export default function getExtensionFromContentType(contentType?: string | null): string | null { if (!contentType) { return null; } diff --git a/src/utility/getUrlExtension.test.ts b/src/utility/getUrlExtension.test.ts index 2c9028da..fb9ab53b 100644 --- a/src/utility/getUrlExtension.test.ts +++ b/src/utility/getUrlExtension.test.ts @@ -7,6 +7,8 @@ describe("getUrlExtension", () => { }); test("should return the extension of a url with params", () => { - expect(getUrlExtension("https://example.com/file.mp3?key=value&key2=.mpegvalue")).toBe("mp3"); + expect(getUrlExtension("https://example.com/file.mp3?key=value&key2=.mpegvalue")).toBe( + "mp3", + ); }); }); diff --git a/src/utility/getUrlExtension.ts b/src/utility/getUrlExtension.ts index 983751f5..233c34c9 100644 --- a/src/utility/getUrlExtension.ts +++ b/src/utility/getUrlExtension.ts @@ -3,11 +3,10 @@ export default function getUrlExtension(url: string): string { const match = regexp.exec(url); if (!match) { - return ''; + return ""; } const [, extension] = match; return extension; } - diff --git a/src/utility/isLocalFile.ts b/src/utility/isLocalFile.ts index ac588f91..ee98bf95 100644 --- a/src/utility/isLocalFile.ts +++ b/src/utility/isLocalFile.ts @@ -2,5 +2,5 @@ import type { LocalEpisode } from "src/types/LocalEpisode"; import type { Episode } from "src/types/Episode"; export function isLocalFile(ep: Episode): ep is LocalEpisode { - return ep.podcastName === "local file"; + return ep.podcastName === "local file"; } diff --git a/src/utility/mediaType.test.ts b/src/utility/mediaType.test.ts index 959b8a01..058ebeae 100644 --- a/src/utility/mediaType.test.ts +++ b/src/utility/mediaType.test.ts @@ -11,9 +11,7 @@ import { describe("mediaType", () => { test("detects media type from enclosure content type", () => { expect(getMediaTypeFromContentType("video/mp4")).toBe("video"); - expect(getMediaTypeFromContentType("Audio/MPEG; charset=utf-8")).toBe( - "audio", - ); + expect(getMediaTypeFromContentType("Audio/MPEG; charset=utf-8")).toBe("audio"); expect(getMediaTypeFromContentType("application/rss+xml")).toBeNull(); }); @@ -116,9 +114,7 @@ describe("mediaType", () => { } satisfies Episode & { filePath: string }; expect(getEpisodeMediaType(episode)).toBe("audio"); - expect(getEpisodeMediaTypeWithContainerHint(episode, "audio")).toBe( - "audio", - ); + expect(getEpisodeMediaTypeWithContainerHint(episode, "audio")).toBe("audio"); }); test("video hints classify legacy ambiguous container file paths as video", () => { @@ -133,9 +129,7 @@ describe("mediaType", () => { } satisfies Episode & { filePath: string }; expect(getEpisodeMediaType(episode)).toBe("audio"); - expect(getEpisodeMediaTypeWithContainerHint(episode, "video")).toBe( - "video", - ); + expect(getEpisodeMediaTypeWithContainerHint(episode, "video")).toBe("video"); }); test("does not let an audio hint override explicit video metadata", () => { @@ -150,9 +144,7 @@ describe("mediaType", () => { filePath: "Podcasts/downloaded-video.mp4", } satisfies Episode & { filePath: string }; - expect(getEpisodeMediaTypeWithContainerHint(episode, "audio")).toBe( - "video", - ); + expect(getEpisodeMediaTypeWithContainerHint(episode, "audio")).toBe("video"); }); test("trusts remote episode media metadata before URL extension fallback", () => { diff --git a/src/utility/mediaType.ts b/src/utility/mediaType.ts index 020b6388..03e6e8c6 100644 --- a/src/utility/mediaType.ts +++ b/src/utility/mediaType.ts @@ -13,22 +13,14 @@ export const AUDIO_MEDIA_EXTENSIONS = new Set([ "amr", ]); -export const VIDEO_MEDIA_EXTENSIONS = new Set([ - "mp4", - "m4v", - "mov", - "webm", - "ogv", -]); +export const VIDEO_MEDIA_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "ogv"]); export const PLAYABLE_MEDIA_EXTENSIONS = new Set([ ...AUDIO_MEDIA_EXTENSIONS, ...VIDEO_MEDIA_EXTENSIONS, ]); -export function getMediaTypeFromExtension( - extension?: string | null, -): EpisodeMediaType | null { +export function getMediaTypeFromExtension(extension?: string | null): EpisodeMediaType | null { if (!extension) return null; const normalizedExtension = extension.toLowerCase(); @@ -42,9 +34,7 @@ export function isPlayableMediaExtension(extension?: string | null): boolean { return getMediaTypeFromExtension(extension) !== null; } -export function getMediaTypeFromContentType( - contentType?: string | null, -): EpisodeMediaType | null { +export function getMediaTypeFromContentType(contentType?: string | null): EpisodeMediaType | null { if (!contentType) return null; const normalizedType = contentType.split(";")[0].trim().toLowerCase(); @@ -54,9 +44,7 @@ export function getMediaTypeFromContentType( return null; } -export function getMediaTypeFromPath( - pathOrUrl?: string | null, -): EpisodeMediaType | null { +export function getMediaTypeFromPath(pathOrUrl?: string | null): EpisodeMediaType | null { if (!pathOrUrl) return null; return getMediaTypeFromExtension(getUrlExtension(pathOrUrl)); @@ -83,9 +71,7 @@ export function getEpisodeMediaType(episode: Episode): EpisodeMediaType { return getUnambiguousMediaTypeFromPath(episode.streamUrl) ?? "audio"; } -export function isAudioContainerExtension( - extension?: string | null, -): boolean { +export function isAudioContainerExtension(extension?: string | null): boolean { if (!extension) return false; const normalizedExtension = extension.toLowerCase(); @@ -132,10 +118,7 @@ export function isSameMediaSource(a: string, b: string): boolean { } } -function stableSearchParamEntriesMatch( - stableA: string[], - stableB: string[], -): boolean { +function stableSearchParamEntriesMatch(stableA: string[], stableB: string[]): boolean { if (stableA.length === 0 || stableB.length === 0) return false; if (stableA.length !== stableB.length) return false; diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts index 128a1957..6003c06a 100644 --- a/src/utility/networkRequest.ts +++ b/src/utility/networkRequest.ts @@ -69,11 +69,7 @@ export async function requestWithTimeout( if (error instanceof NetworkError) { throw error; } - throw new NetworkError( - error instanceof Error ? error.message : String(error), - url, - error, - ); + throw new NetworkError(error instanceof Error ? error.message : String(error), url, error); } finally { if (timeoutId) { window.clearTimeout(timeoutId); diff --git a/src/utility/parseDuration.ts b/src/utility/parseDuration.ts index bd6c8e19..7b810687 100644 --- a/src/utility/parseDuration.ts +++ b/src/utility/parseDuration.ts @@ -10,9 +10,7 @@ const MAX_PLAUSIBLE_SECONDS = 86400 * 366; * Colon segments are summed as-is (a non-normalized "1:90" becomes 150). Returns * `undefined` for empty, malformed, non-numeric, or implausibly large input. */ -export function parseDurationToSeconds( - value: string | null | undefined, -): number | undefined { +export function parseDurationToSeconds(value: string | null | undefined): number | undefined { if (value === null || value === undefined) return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; diff --git a/src/utility/parseEpisodeNumber.test.ts b/src/utility/parseEpisodeNumber.test.ts index 06b1eb5f..e64ed039 100644 --- a/src/utility/parseEpisodeNumber.test.ts +++ b/src/utility/parseEpisodeNumber.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - parseEpisodeNumber, - parseEpisodeNumberFromTitle, -} from "./parseEpisodeNumber"; +import { parseEpisodeNumber, parseEpisodeNumberFromTitle } from "./parseEpisodeNumber"; describe("parseEpisodeNumber", () => { it("prefers a numeric <itunes:episode> value", () => { diff --git a/src/utility/parseEpisodeNumber.ts b/src/utility/parseEpisodeNumber.ts index 9653498f..686a0a01 100644 --- a/src/utility/parseEpisodeNumber.ts +++ b/src/utility/parseEpisodeNumber.ts @@ -25,9 +25,7 @@ export function parseEpisodeNumber( return parseEpisodeNumberFromTitle(title); } -function parseNonNegativeInt( - value: string | null | undefined, -): number | undefined { +function parseNonNegativeInt(value: string | null | undefined): number | undefined { if (value === null || value === undefined) return undefined; const trimmed = value.trim(); if (!/^\d+$/.test(trimmed)) return undefined; @@ -41,9 +39,7 @@ function parseNonNegativeInt( * Best-effort extraction of an episode number from the start of a title. Exported * for focused unit testing of the heuristics; prefer {@link parseEpisodeNumber}. */ -export function parseEpisodeNumberFromTitle( - title: string | null | undefined, -): number | undefined { +export function parseEpisodeNumberFromTitle(title: string | null | undefined): number | undefined { const trimmed = (title ?? "").trim(); if (!trimmed) return undefined; diff --git a/src/utility/playbackRate.ts b/src/utility/playbackRate.ts index f3c42dc3..b139abc3 100644 --- a/src/utility/playbackRate.ts +++ b/src/utility/playbackRate.ts @@ -3,14 +3,9 @@ export const PLAYBACK_RATE_MAX = 4; export const PLAYBACK_RATE_STEP = 0.1; export const DEFAULT_PLAYBACK_RATE = 1; -export function normalizePlaybackRate( - value: unknown, - fallback = DEFAULT_PLAYBACK_RATE, -): number { +export function normalizePlaybackRate(value: unknown, fallback = DEFAULT_PLAYBACK_RATE): number { const numeric = typeof value === "number" ? value : Number(value); - const fallbackRate = Number.isFinite(fallback) - ? fallback - : DEFAULT_PLAYBACK_RATE; + const fallbackRate = Number.isFinite(fallback) ? fallback : DEFAULT_PLAYBACK_RATE; if (!Number.isFinite(numeric)) { return clampPlaybackRate(fallbackRate); @@ -19,18 +14,12 @@ export function normalizePlaybackRate( return clampPlaybackRate(numeric); } -export function adjustPlaybackRate( - currentRate: number, - delta: number, -): number { +export function adjustPlaybackRate(currentRate: number, delta: number): number { return normalizePlaybackRate(roundToTenths(currentRate + delta)); } function clampPlaybackRate(value: number): number { - return Math.min( - PLAYBACK_RATE_MAX, - Math.max(PLAYBACK_RATE_MIN, roundToTenths(value)), - ); + return Math.min(PLAYBACK_RATE_MAX, Math.max(PLAYBACK_RATE_MIN, roundToTenths(value))); } function roundToTenths(value: number): number { diff --git a/src/utility/podcastSegment.test.ts b/src/utility/podcastSegment.test.ts index 72fed57a..98193963 100644 --- a/src/utility/podcastSegment.test.ts +++ b/src/utility/podcastSegment.test.ts @@ -45,26 +45,18 @@ describe("createRecentPodcastSegment", () => { describe("formatPodcastSegment", () => { test("formats start and end with the same clock format", () => { - expect(formatPodcastSegment(115, 125, "HH:mm:ss")).toBe( - "00:01:55-00:02:05", - ); + expect(formatPodcastSegment(115, 125, "HH:mm:ss")).toBe("00:01:55-00:02:05"); }); }); describe("getSegmentCaptureTemplate", () => { test("turns the default timestamp tags into segment tags", () => { - expect(getSegmentCaptureTemplate("- {{linktime}}")).toBe( - "- {{linksegment}}", - ); - expect(getSegmentCaptureTemplate("- {{time:mm:ss}}")).toBe( - "- {{segment:mm:ss}}", - ); + expect(getSegmentCaptureTemplate("- {{linktime}}")).toBe("- {{linksegment}}"); + expect(getSegmentCaptureTemplate("- {{time:mm:ss}}")).toBe("- {{segment:mm:ss}}"); }); test("preserves an explicit segment template", () => { - expect(getSegmentCaptureTemplate("> {{linksegment}}")).toBe( - "> {{linksegment}}", - ); + expect(getSegmentCaptureTemplate("> {{linksegment}}")).toBe("> {{linksegment}}"); }); test("falls back to a linked segment when no time tag exists", () => { diff --git a/src/utility/podcastSegment.ts b/src/utility/podcastSegment.ts index 9c746a17..c54ed88e 100644 --- a/src/utility/podcastSegment.ts +++ b/src/utility/podcastSegment.ts @@ -26,11 +26,7 @@ export function createRecentPodcastSegment( lengthSeconds: number, offsetSeconds = 0, ): PodcastSegmentTimes | null { - if ( - !Number.isFinite(currentTime) || - !Number.isFinite(lengthSeconds) || - lengthSeconds <= 0 - ) { + if (!Number.isFinite(currentTime) || !Number.isFinite(lengthSeconds) || lengthSeconds <= 0) { return null; } @@ -40,11 +36,7 @@ export function createRecentPodcastSegment( return normalizePodcastSegmentTimes(startTime, endTime); } -export function formatPodcastSegment( - startTime: number, - endTime: number, - format: string, -): string { +export function formatPodcastSegment(startTime: number, endTime: number, format: string): string { return `${formatSeconds(Math.max(0, startTime), format)}-${formatSeconds( Math.max(0, endTime), format, @@ -56,14 +48,8 @@ export function getSegmentCaptureTemplate(template: string): string { return template; } - const withLinkSegment = template.replace( - /\{\{linktime(:\s*?.+?)?\}\}/gi, - "{{linksegment$1}}", - ); - const withSegment = withLinkSegment.replace( - /\{\{time(:\s*?.+?)?\}\}/gi, - "{{segment$1}}", - ); + const withLinkSegment = template.replace(/\{\{linktime(:\s*?.+?)?\}\}/gi, "{{linksegment$1}}"); + const withSegment = withLinkSegment.replace(/\{\{time(:\s*?.+?)?\}\}/gi, "{{segment$1}}"); return withSegment === template ? "- {{linksegment}}" : withSegment; } diff --git a/src/utility/prepareTimestampInsertion.test.ts b/src/utility/prepareTimestampInsertion.test.ts index 08d08a48..808f03ee 100644 --- a/src/utility/prepareTimestampInsertion.test.ts +++ b/src/utility/prepareTimestampInsertion.test.ts @@ -15,12 +15,7 @@ function fromLines(lines: string[]): { return { getLine: (line: number) => lines[line] ?? "", lineCount: lines.length }; } -const TABLE = [ - "| Time | Note |", - "| ---- | ---- |", - "| 0:00 | intro |", - "| 1:23 | topic |", -]; +const TABLE = ["| Time | Note |", "| ---- | ---- |", "| 0:00 | intro |", "| 1:23 | topic |"]; describe("isTableDelimiterRow", () => { it("recognises plain, aligned, and tight delimiter rows", () => { diff --git a/src/utility/prepareTimestampInsertion.ts b/src/utility/prepareTimestampInsertion.ts index c8442685..2428fbd5 100644 --- a/src/utility/prepareTimestampInsertion.ts +++ b/src/utility/prepareTimestampInsertion.ts @@ -36,10 +36,7 @@ export function isTableDelimiterRow(line: string): boolean { const trimmed = stripBlockquotePrefix(line).trim(); if (!trimmed.includes("|")) return false; - const cells = trimmed - .replace(/^\|/, "") - .replace(/\|$/, "") - .split("|"); + const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|"); return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell)); } diff --git a/src/utility/searchEpisodes.test.ts b/src/utility/searchEpisodes.test.ts index 9b7f8043..38fcc047 100644 --- a/src/utility/searchEpisodes.test.ts +++ b/src/utility/searchEpisodes.test.ts @@ -25,10 +25,7 @@ vi.mock("fuse.js", async (importOriginal) => { import searchEpisodes from "./searchEpisodes"; -function makeEpisode( - title: string, - streamUrl = `https://example.com/${title}.mp3`, -): Episode { +function makeEpisode(title: string, streamUrl = `https://example.com/${title}.mp3`): Episode { return { title, streamUrl, diff --git a/src/utility/searchEpisodes.ts b/src/utility/searchEpisodes.ts index cfa1851c..cd8b4804 100644 --- a/src/utility/searchEpisodes.ts +++ b/src/utility/searchEpisodes.ts @@ -2,17 +2,14 @@ import Fuse from "fuse.js"; import type { Episode } from "src/types/Episode"; const fuseOptions = { - shouldSort: true, - findAllMatches: true, - threshold: 0.4, - isCaseSensitive: false, - keys: ["title"], + shouldSort: true, + findAllMatches: true, + threshold: 0.4, + isCaseSensitive: false, + keys: ["title"], }; -const fuseCache = new WeakMap< - Episode[], - { fuse: Fuse<Episode>; signature: string } ->(); +const fuseCache = new WeakMap<Episode[], { fuse: Fuse<Episode>; signature: string }>(); // Fingerprint the searchable content of the list. The Fuse index is keyed by the // array reference, but the same reference can be mutated in place (an entry @@ -24,36 +21,34 @@ const fuseCache = new WeakMap< // list keeps reusing it across keystrokes. JSON framing keeps it collision-free // regardless of what the titles and URLs contain. function contentSignature(episodes: Episode[]): string { - return JSON.stringify( - episodes.map((episode) => [episode.title, episode.streamUrl]), - ); + return JSON.stringify(episodes.map((episode) => [episode.title, episode.streamUrl])); } function getFuse(episodes: Episode[]): Fuse<Episode> { - const signature = contentSignature(episodes); - const cached = fuseCache.get(episodes); + const signature = contentSignature(episodes); + const cached = fuseCache.get(episodes); - if (cached && cached.signature === signature) { - return cached.fuse; - } + if (cached && cached.signature === signature) { + return cached.fuse; + } - const newFuse = new Fuse(episodes, fuseOptions); - fuseCache.set(episodes, { fuse: newFuse, signature }); + const newFuse = new Fuse(episodes, fuseOptions); + fuseCache.set(episodes, { fuse: newFuse, signature }); - return newFuse; + return newFuse; } export default function searchEpisodes(query: string, episodes: Episode[]): Episode[] { - if (episodes.length === 0) return []; - - // Trim before the empty check so a whitespace-only query restores the full - // list rather than running a fuzzy search for spaces. This covers every call - // site (feed/playlist/latest/played searches) at once (PV-08). - if (query.trim().length === 0) { - return episodes; - } - - const fuse = getFuse(episodes); - const searchResults = fuse.search(query); - return searchResults.map(resItem => resItem.item); + if (episodes.length === 0) return []; + + // Trim before the empty check so a whitespace-only query restores the full + // list rather than running a fuzzy search for spaces. This covers every call + // site (feed/playlist/latest/played searches) at once (PV-08). + if (query.trim().length === 0) { + return episodes; + } + + const fuse = getFuse(episodes); + const searchResults = fuse.search(query); + return searchResults.map((resItem) => resItem.item); } diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 24f8ece7..71608622 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -194,9 +194,7 @@ export class SliderComponent extends BaseInteractiveElement { } onChange(callback: (value: number) => void) { - this.sliderEl.addEventListener("input", () => - callback(Number(this.sliderEl.value)), - ); + this.sliderEl.addEventListener("input", () => callback(Number(this.sliderEl.value))); return this; } } @@ -226,9 +224,7 @@ export class TextComponent extends BaseInteractiveElement { } onChange(callback: (value: string) => void) { - this.inputEl.addEventListener("input", () => - callback(this.inputEl.value), - ); + this.inputEl.addEventListener("input", () => callback(this.inputEl.value)); return this; } } diff --git a/vitest.setup.ts b/vitest.setup.ts index 1aab8a1d..0d8a3e32 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -93,15 +93,7 @@ function createMoment(dateInput?: Date | string | number) { format: (pattern: string = "YYYY-MM-DD") => formatDate(date, pattern), startOf: (unit?: string) => { if (unit === "day") { - date = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - 0, - 0, - 0, - 0, - ); + date = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); } return api; @@ -174,26 +166,33 @@ if (typeof IntersectionObserver === "undefined") { unobserve(): void {} } - (globalThis as unknown as { IntersectionObserver: typeof MockIntersectionObserver }) - .IntersectionObserver = MockIntersectionObserver; + ( + globalThis as unknown as { IntersectionObserver: typeof MockIntersectionObserver } + ).IntersectionObserver = MockIntersectionObserver; } if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } -if (!(HTMLElement.prototype as unknown as { setAttr?: (name: string, value: string) => void }).setAttr) { - (HTMLElement.prototype as unknown as { setAttr: (name: string, value: string) => void }).setAttr = - function (this: HTMLElement, name: string, value: string) { - this.setAttribute(name, value); - }; +if ( + !(HTMLElement.prototype as unknown as { setAttr?: (name: string, value: string) => void }) + .setAttr +) { + ( + HTMLElement.prototype as unknown as { setAttr: (name: string, value: string) => void } + ).setAttr = function (this: HTMLElement, name: string, value: string) { + this.setAttribute(name, value); + }; } if (!(HTMLElement.prototype as unknown as { setText?: (text: string) => void }).setText) { - (HTMLElement.prototype as unknown as { setText: (text: string) => void }).setText = - function (this: HTMLElement, text: string) { - this.textContent = text; - }; + (HTMLElement.prototype as unknown as { setText: (text: string) => void }).setText = function ( + this: HTMLElement, + text: string, + ) { + this.textContent = text; + }; } type ObsidianDomContainer = HTMLElement | DocumentFragment; @@ -201,10 +200,7 @@ type CreateElOptions = { text?: string; cls?: string }; function installCreateEl(proto: object): void { const helpers = proto as { - createEl?: ( - tag: keyof HTMLElementTagNameMap, - options?: CreateElOptions, - ) => HTMLElement; + createEl?: (tag: keyof HTMLElementTagNameMap, options?: CreateElOptions) => HTMLElement; createDiv?: (options?: CreateElOptions) => HTMLDivElement; }; @@ -223,10 +219,7 @@ function installCreateEl(proto: object): void { } if (!helpers.createDiv) { - helpers.createDiv = function ( - this: ObsidianDomContainer, - options: CreateElOptions = {}, - ) { + helpers.createDiv = function (this: ObsidianDomContainer, options: CreateElOptions = {}) { const createEl = ( this as ObsidianDomContainer & { createEl: ( @@ -244,28 +237,24 @@ installCreateEl(HTMLElement.prototype); installCreateEl(DocumentFragment.prototype); if (!(HTMLElement.prototype as unknown as { empty?: () => void }).empty) { - (HTMLElement.prototype as unknown as { empty: () => void }).empty = - function (this: HTMLElement) { - while (this.firstChild) { - this.removeChild(this.firstChild); - } - }; + (HTMLElement.prototype as unknown as { empty: () => void }).empty = function ( + this: HTMLElement, + ) { + while (this.firstChild) { + this.removeChild(this.firstChild); + } + }; } // Obsidian augments HTMLElement with setCssStyles (assigns a batch of inline // styles, the sanctioned alternative to direct `el.style.x = y` writes). jsdom // has no such method, so mirror Obsidian's behaviour for component/DOM tests. -if ( - !(HTMLElement.prototype as unknown as { setCssStyles?: unknown }).setCssStyles -) { +if (!(HTMLElement.prototype as unknown as { setCssStyles?: unknown }).setCssStyles) { ( HTMLElement.prototype as unknown as { setCssStyles: (styles: Partial<CSSStyleDeclaration>) => void; } - ).setCssStyles = function ( - this: HTMLElement, - styles: Partial<CSSStyleDeclaration>, - ) { + ).setCssStyles = function (this: HTMLElement, styles: Partial<CSSStyleDeclaration>) { Object.assign(this.style, styles); }; } @@ -281,35 +270,34 @@ if ( // assert mid-transition or outro-timing behaviour, replace this with a fuller // fake (e.g. a timer-driven animation) instead of relying on these defaults. if (!Element.prototype.animate) { - (Element.prototype as unknown as { animate: () => Animation }).animate = - function () { - let onfinish: (() => void) | null = null; - const animation = { - cancel() {}, - finish() {}, - play() {}, - pause() {}, - reverse() {}, - currentTime: 0, - startTime: 0, - playbackRate: 1, - playState: "finished", - finished: Promise.resolve(), - effect: null, - addEventListener() {}, - removeEventListener() {}, - get onfinish() { - return onfinish; - }, - set onfinish(fn: (() => void) | null) { - onfinish = fn; - if (fn) { - queueMicrotask(() => fn()); - } - }, - oncancel: null, - }; - - return animation as unknown as Animation; + (Element.prototype as unknown as { animate: () => Animation }).animate = function () { + let onfinish: (() => void) | null = null; + const animation = { + cancel() {}, + finish() {}, + play() {}, + pause() {}, + reverse() {}, + currentTime: 0, + startTime: 0, + playbackRate: 1, + playState: "finished", + finished: Promise.resolve(), + effect: null, + addEventListener() {}, + removeEventListener() {}, + get onfinish() { + return onfinish; + }, + set onfinish(fn: (() => void) | null) { + onfinish = fn; + if (fn) { + queueMicrotask(() => fn()); + } + }, + oncancel: null, }; + + return animation as unknown as Animation; + }; } diff --git a/wrangler.jsonc b/wrangler.jsonc index 34586b90..b85a87dd 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,5 +1,5 @@ { "name": "podnotes", "compatibility_date": "2026-04-30", - "pages_build_output_dir": "./docs/site" + "pages_build_output_dir": "./docs/site", }