diff --git a/__tests__/__mocks__/obsidian.ts b/__tests__/__mocks__/obsidian.ts index f82d3c9..0031ae3 100644 --- a/__tests__/__mocks__/obsidian.ts +++ b/__tests__/__mocks__/obsidian.ts @@ -116,6 +116,21 @@ export class PluginSettingTab { } display(): void {} hide(): void {} + update(): void {} + refreshDomState(): void {} + getControlValue(_key: string): unknown { + return undefined; + } + setControlValue(_key: string, _value: unknown): void {} +} + +export abstract class SettingPage { + rootEl: ChainableStub = new ChainableStub(); + titlebarEl: ChainableStub = new ChainableStub(); + containerEl: ChainableStub = new ChainableStub(); + title = ""; + abstract display(): void; + hide(): void {} } export class Setting { diff --git a/__tests__/image-downloader.test.ts b/__tests__/image-downloader.test.ts new file mode 100644 index 0000000..fafdd49 --- /dev/null +++ b/__tests__/image-downloader.test.ts @@ -0,0 +1,268 @@ +import type { HttpFetcher, HttpRequest, HttpResponse } from "../feed-source"; +import { + ImageDownloader, + type BinaryFileLike, + type VaultBinaryLike, +} from "../image-downloader"; + +// ----------------------------------------------------------------------------- +// Fixtures +// ----------------------------------------------------------------------------- + +/** Turn an ASCII string into an ArrayBuffer for a fake response body. */ +function bytes(text: string): ArrayBuffer { + const arr = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) { + arr[i] = text.charCodeAt(i); + } + return arr.buffer; +} + +interface FetchEntry { + status?: number; + headers?: Record; + body?: ArrayBuffer; + throws?: Error; +} + +/** Fetcher stub keyed by url; records the urls it was asked for. */ +function makeFetcher(map: Record): { + fetcher: HttpFetcher; + requested: string[]; +} { + const requested: string[] = []; + const fetcher: HttpFetcher = (req: HttpRequest) => { + requested.push(req.url); + const entry = map[req.url]; + if (entry === undefined) { + return Promise.reject(new Error(`no stub for ${req.url}`)); + } + if (entry.throws !== undefined) { + return Promise.reject(entry.throws); + } + const response: HttpResponse = { + status: entry.status ?? 200, + headers: entry.headers ?? {}, + json: null, + text: "", + arrayBuffer: entry.body ?? bytes("IMG"), + }; + return Promise.resolve(response); + }; + return { fetcher, requested }; +} + +/** In-memory vault that records createBinary / createFolder calls. */ +class FakeVault implements VaultBinaryLike { + files = new Set(); + folders = new Set(); + binaries: Array<{ path: string; size: number }> = []; + createdFolders: string[] = []; + + getFileByPath(path: string): BinaryFileLike | null { + return this.files.has(path) ? { path } : null; + } + getFolderByPath(path: string): BinaryFileLike | null { + return this.folders.has(path) ? { path } : null; + } + createFolder(path: string): Promise { + this.folders.add(path); + this.createdFolders.push(path); + return Promise.resolve({ path }); + } + createBinary(path: string, data: ArrayBuffer): Promise { + this.files.add(path); + this.binaries.push({ path, size: data.byteLength }); + return Promise.resolve({ path }); + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +describe("ImageDownloader.downloadAndRewrite", () => { + let errorSpy: jest.SpyInstance; + beforeEach(() => { + errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("downloads an image, writes binary, and rewrites the reference", async () => { + const url = "https://cdn.example.com/pics/photo.png"; + const { fetcher, requested } = makeFetcher({ [url]: { body: bytes("PNGDATA") } }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const md = `Before\n\n![a cat](${url})\n\nAfter`; + const out = await dl.downloadAndRewrite(md, "Feeds/images"); + + expect(requested).toEqual([url]); + expect(vault.binaries).toHaveLength(1); + expect(vault.binaries[0]?.path).toBe("Feeds/images/photo.png"); + expect(vault.binaries[0]?.size).toBe("PNGDATA".length); + // Reference rewritten to the local path; alt preserved. + expect(out).toContain("![a cat](Feeds/images/photo.png)"); + expect(out).not.toContain(url); + // Surrounding prose is untouched. + expect(out).toContain("Before"); + expect(out).toContain("After"); + }); + + it("creates the destination folder and its ancestors", async () => { + const url = "https://x.test/a.jpg"; + const { fetcher } = makeFetcher({ [url]: { body: bytes("J") } }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + await dl.downloadAndRewrite(`![](${url})`, "Feeds/Blog/images"); + + expect(vault.createdFolders).toEqual(["Feeds", "Feeds/Blog", "Feeds/Blog/images"]); + }); + + it("leaves the original url in place when a download fails (transport error)", async () => { + const ok = "https://x.test/ok.png"; + const bad = "https://x.test/bad.png"; + const { fetcher } = makeFetcher({ + [ok]: { body: bytes("OK") }, + [bad]: { throws: new Error("connection reset") }, + }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const md = `![one](${ok})\n\n![two](${bad})`; + const out = await dl.downloadAndRewrite(md, "images"); + + // Good one is rewritten; bad one keeps its remote url verbatim. + expect(out).toContain("![one](images/ok.png)"); + expect(out).toContain(`![two](${bad})`); + // Only the good image was written. + expect(vault.binaries.map((b) => b.path)).toEqual(["images/ok.png"]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("leaves the url in place on a non-2xx status", async () => { + const url = "https://x.test/missing.png"; + const { fetcher } = makeFetcher({ [url]: { status: 404, body: bytes("nope") } }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const out = await dl.downloadAndRewrite(`![x](${url})`, "images"); + + expect(out).toBe(`![x](${url})`); + expect(vault.binaries).toHaveLength(0); + }); + + it("leaves the url in place on an empty response body", async () => { + const url = "https://x.test/empty.png"; + const { fetcher } = makeFetcher({ [url]: { body: new ArrayBuffer(0) } }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const out = await dl.downloadAndRewrite(`![x](${url})`, "images"); + + expect(out).toBe(`![x](${url})`); + expect(vault.binaries).toHaveLength(0); + }); + + it("derives the extension from the content-type when the url has none", async () => { + const url = "https://x.test/image"; + const { fetcher } = makeFetcher({ + [url]: { headers: { "Content-Type": "image/webp" }, body: bytes("W") }, + }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + await dl.downloadAndRewrite(`![](${url})`, "images"); + + expect(vault.binaries[0]?.path).toMatch(/^images\/.+\.webp$/); + }); + + it("downloads a repeated url only once and rewrites every occurrence", async () => { + const url = "https://x.test/dup.png"; + const { fetcher, requested } = makeFetcher({ [url]: { body: bytes("D") } }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const md = `![first](${url})\n\n![second](${url})`; + const out = await dl.downloadAndRewrite(md, "images"); + + expect(requested).toEqual([url]); + expect(vault.binaries).toHaveLength(1); + expect(out).toContain("![first](images/dup.png)"); + expect(out).toContain("![second](images/dup.png)"); + }); + + it("disambiguates distinct urls that share a base filename", async () => { + const a = "https://a.test/photo.png"; + const b = "https://b.test/photo.png"; + const { fetcher } = makeFetcher({ + [a]: { body: bytes("A") }, + [b]: { body: bytes("B") }, + }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const out = await dl.downloadAndRewrite(`![](${a}) ![](${b})`, "images"); + + const paths = vault.binaries.map((x) => x.path); + expect(paths).toContain("images/photo.png"); + expect(paths).toContain("images/photo-1.png"); + expect(new Set(paths).size).toBe(2); + expect(out).toContain("images/photo.png"); + expect(out).toContain("images/photo-1.png"); + }); + + it("ignores non-http(s) image references (data: and relative)", async () => { + const remote = "https://x.test/keep.png"; + const { fetcher, requested } = makeFetcher({ [remote]: { body: bytes("K") } }); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const md = [ + "![data](data:image/png;base64,AAAA)", + "![rel](./local/pic.png)", + `![remote](${remote})`, + ].join("\n\n"); + const out = await dl.downloadAndRewrite(md, "images"); + + // Only the remote image was fetched. + expect(requested).toEqual([remote]); + // Non-http refs are left exactly as written. + expect(out).toContain("![data](data:image/png;base64,AAAA)"); + expect(out).toContain("![rel](./local/pic.png)"); + expect(out).toContain("![remote](images/keep.png)"); + }); + + it("returns markdown unchanged when there are no image references", async () => { + const { fetcher, requested } = makeFetcher({}); + const vault = new FakeVault(); + const dl = new ImageDownloader({ fetcher, vault }); + + const md = "Just text, [a link](https://x.test/page) but no images."; + const out = await dl.downloadAndRewrite(md, "images"); + + expect(out).toBe(md); + expect(requested).toHaveLength(0); + expect(vault.createdFolders).toHaveLength(0); + }); + + it("returns markdown unchanged when folder creation fails", async () => { + const url = "https://x.test/a.png"; + const { fetcher, requested } = makeFetcher({ [url]: { body: bytes("A") } }); + const vault = new FakeVault(); + vault.createFolder = (): Promise => + Promise.reject(new Error("cannot create folder")); + const dl = new ImageDownloader({ fetcher, vault }); + + const md = `![x](${url})`; + const out = await dl.downloadAndRewrite(md, "images"); + + expect(out).toBe(md); + // We never attempted the download once the folder failed. + expect(requested).toHaveLength(0); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/import-runner.test.ts b/__tests__/import-runner.test.ts new file mode 100644 index 0000000..b9afa45 --- /dev/null +++ b/__tests__/import-runner.test.ts @@ -0,0 +1,361 @@ +import type { FeedItem } from "../feed-source"; +import type { NoteWriter, WriteOutcome } from "../note-writer"; +import { NoteWriterCancelledError } from "../note-writer"; +import { + ImportRunner, + formatImportNotice, + type FeedSourceLike, + type ImportProgress, + type ImportTally, +} from "../import-runner"; + +// ----------------------------------------------------------------------------- +// Fixtures +// ----------------------------------------------------------------------------- + +function makeItem(overrides: Partial = {}): FeedItem { + return { + sourceId: "feed-1", + id: "item-1", + url: "https://example.com/p/one", + title: "Item One", + author: "Author", + publishedAt: "2026-01-02T00:00:00.000Z", + kind: "article", + contentHtml: null, + isTruncated: false, + audience: "free", + tags: [], + section: null, + mediaUrl: null, + mediaType: null, + mediaBytes: null, + ...overrides, + }; +} + +/** Source stub: fetchBody fills contentHtml from a per-id map, or throws. */ +function makeSource( + bodies: Record, +): { source: FeedSourceLike; fetched: string[] } { + const fetched: string[] = []; + const source: FeedSourceLike = { + fetchBody(item: FeedItem): Promise { + fetched.push(item.id); + const entry = bodies[item.id]; + if (entry instanceof Error) { + return Promise.reject(entry); + } + return Promise.resolve({ ...item, contentHtml: entry ?? "

x

" }); + }, + }; + return { source, fetched }; +} + +/** + * NoteWriter stub. Casts a plain object to NoteWriter because the runner only + * ever calls writeNote; the structural shape is all that is exercised. + */ +function makeWriter( + behavior: (item: FeedItem, body: string) => Promise, +): { writer: NoteWriter; calls: Array<{ id: string; body: string }> } { + const calls: Array<{ id: string; body: string }> = []; + const stub = { + writeNote(item: FeedItem, body: string): Promise { + calls.push({ id: item.id, body }); + return behavior(item, body); + }, + }; + return { writer: stub as unknown as NoteWriter, calls }; +} + +const identityConvert = (html: string): string => `MD:${html}`; + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +describe("ImportRunner.run", () => { + let errorSpy: jest.SpyInstance; + beforeEach(() => { + errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("tallies created/overwritten/skipped across a clean batch", async () => { + const items = [ + makeItem({ id: "a" }), + makeItem({ id: "b" }), + makeItem({ id: "c" }), + ]; + const { source } = makeSource({ a: "

A

", b: "

B

", c: "

C

" }); + const statuses: Record = { + a: { status: "created", path: "Feeds/a.md" }, + b: { status: "overwritten", path: "Feeds/b.md" }, + c: { status: "skipped", path: "Feeds/c.md" }, + }; + const { writer } = makeWriter((item) => { + const out = statuses[item.id]; + if (out === undefined) throw new Error("unexpected item"); + return Promise.resolve(out); + }); + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + const tally = await runner.run(items, {}); + + expect(tally.total).toBe(3); + expect(tally.created).toBe(1); + expect(tally.overwritten).toBe(1); + expect(tally.skipped).toBe(1); + expect(tally.failed).toBe(0); + expect(tally.results.map((r) => r.status)).toEqual([ + "created", + "overwritten", + "skipped", + ]); + expect(tally.results[0]?.path).toBe("Feeds/a.md"); + expect(tally.results[0]?.reason).toBeNull(); + }); + + it("passes the converted body and feedTags to the writer", async () => { + const items = [makeItem({ id: "a" })]; + const { source } = makeSource({ a: "

hello

" }); + const { writer, calls } = makeWriter(() => + Promise.resolve({ status: "created", path: "Feeds/a.md" }), + ); + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + await runner.run(items, { feedTags: ["news"] }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toBe("MD:

hello

"); + }); + + it("records a failed item without aborting the rest of the batch", async () => { + const items = [ + makeItem({ id: "a" }), + makeItem({ id: "boom" }), + makeItem({ id: "c" }), + ]; + // 'boom' throws at fetch time; a and c succeed. + const { source, fetched } = makeSource({ + a: "

A

", + boom: new Error("fetch exploded"), + c: "

C

", + }); + const { writer } = makeWriter((item) => + Promise.resolve({ status: "created", path: `Feeds/${item.id}.md` }), + ); + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + const tally = await runner.run(items, {}); + + // All three were attempted; the batch did not stop on the bad item. + expect(fetched).toEqual(["a", "boom", "c"]); + expect(tally.failed).toBe(1); + expect(tally.created).toBe(2); + const failed = tally.results.find((r) => r.item.id === "boom"); + expect(failed?.status).toBe("failed"); + expect(failed?.reason).toBe("fetch exploded"); + expect(failed?.path).toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("records a write failure as failed with the writer's message", async () => { + const items = [makeItem({ id: "a" })]; + const { source } = makeSource({ a: "

A

" }); + const { writer } = makeWriter(() => + Promise.reject(new Error("disk full")), + ); + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + const tally = await runner.run(items, {}); + + expect(tally.failed).toBe(1); + expect(tally.results[0]?.reason).toBe("disk full"); + }); + + it("aborts the whole run when the writer reports a user cancel", async () => { + const items = [ + makeItem({ id: "a" }), + makeItem({ id: "b" }), + makeItem({ id: "c" }), + ]; + const { source, fetched } = makeSource({ a: "

A

", b: "

B

", c: "

C

" }); + const { writer } = makeWriter((item) => { + if (item.id === "b") { + return Promise.reject(new NoteWriterCancelledError()); + } + return Promise.resolve({ status: "created", path: `Feeds/${item.id}.md` }); + }); + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + + await expect(runner.run(items, {})).rejects.toBeInstanceOf(NoteWriterCancelledError); + // 'c' was never fetched: the cancel unwound the loop at 'b'. + expect(fetched).toEqual(["a", "b"]); + }); + + it("stops early when isAborted() returns true and returns work done so far", async () => { + const items = [ + makeItem({ id: "a" }), + makeItem({ id: "b" }), + makeItem({ id: "c" }), + ]; + const { source, fetched } = makeSource({ a: "

A

", b: "

B

", c: "

C

" }); + const { writer } = makeWriter((item) => + Promise.resolve({ status: "created", path: `Feeds/${item.id}.md` }), + ); + + // Abort before the 2nd item (index 1): allow index 0, then stop. + let calls = 0; + const isAborted = (): boolean => { + const abort = calls >= 1; + calls++; + return abort; + }; + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + const tally = await runner.run(items, { isAborted }); + + expect(fetched).toEqual(["a"]); + expect(tally.created).toBe(1); + expect(tally.results).toHaveLength(1); + }); + + it("invokes onProgress once per processed item with the fetched item", async () => { + const items = [makeItem({ id: "a" }), makeItem({ id: "b" })]; + const { source } = makeSource({ a: "

A

", b: "

B

" }); + const { writer } = makeWriter((item) => + Promise.resolve({ status: "created", path: `Feeds/${item.id}.md` }), + ); + const progress: ImportProgress[] = []; + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + await runner.run(items, { onProgress: (p) => progress.push(p) }); + + expect(progress).toHaveLength(2); + expect(progress[0]?.index).toBe(0); + expect(progress[0]?.total).toBe(2); + // The progress item carries the fetched body, not the bare input. + expect(progress[0]?.item.contentHtml).toBe("

A

"); + expect(progress[1]?.index).toBe(1); + }); + + it("keeps converted markdown when processImages throws (best effort)", async () => { + const items = [makeItem({ id: "a" })]; + const { source } = makeSource({ a: "

A

" }); + const { writer, calls } = makeWriter(() => + Promise.resolve({ status: "created", path: "Feeds/a.md" }), + ); + const processImages = (): Promise => + Promise.reject(new Error("image host down")); + + const runner = new ImportRunner({ + source, + noteWriter: writer, + convert: identityConvert, + processImages, + }); + const tally = await runner.run(items, {}); + + // Item still succeeds; the body is the un-rewritten converted markdown. + expect(tally.created).toBe(1); + expect(tally.failed).toBe(0); + expect(calls[0]?.body).toBe("MD:

A

"); + }); + + it("uses the processImages result when it succeeds", async () => { + const items = [makeItem({ id: "a" })]; + const { source } = makeSource({ a: "

A

" }); + const { writer, calls } = makeWriter(() => + Promise.resolve({ status: "created", path: "Feeds/a.md" }), + ); + const processImages = (md: string): Promise => + Promise.resolve(`${md} +imgs`); + + const runner = new ImportRunner({ + source, + noteWriter: writer, + convert: identityConvert, + processImages, + }); + await runner.run(items, {}); + + expect(calls[0]?.body).toBe("MD:

A

+imgs"); + }); + + it("treats null contentHtml as an empty body", async () => { + const items = [makeItem({ id: "a" })]; + const source: FeedSourceLike = { + fetchBody: (item) => Promise.resolve({ ...item, contentHtml: null }), + }; + const { writer, calls } = makeWriter(() => + Promise.resolve({ status: "created", path: "Feeds/a.md" }), + ); + + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + await runner.run(items, {}); + + expect(calls[0]?.body).toBe("MD:"); + }); + + it("returns a zeroed tally for an empty item list", async () => { + const { source } = makeSource({}); + const { writer, calls } = makeWriter(() => + Promise.resolve({ status: "created", path: "x" }), + ); + const runner = new ImportRunner({ source, noteWriter: writer, convert: identityConvert }); + const tally = await runner.run([], {}); + + expect(tally).toEqual({ + total: 0, + created: 0, + overwritten: 0, + skipped: 0, + failed: 0, + results: [], + }); + expect(calls).toHaveLength(0); + }); +}); + +describe("formatImportNotice", () => { + it("folds created and overwritten into one imported count", () => { + const tally: ImportTally = { + total: 5, + created: 2, + overwritten: 1, + skipped: 1, + failed: 1, + results: [], + }; + expect(formatImportNotice(tally)).toBe("Imported 3, skipped 1, failed 1"); + }); + + it("reads exactly as the documented summary for a 3/1/0 run", () => { + const tally: ImportTally = { + total: 4, + created: 3, + overwritten: 0, + skipped: 1, + failed: 0, + results: [], + }; + expect(formatImportNotice(tally)).toBe("Imported 3, skipped 1, failed 0"); + }); + + it("shows zeros explicitly", () => { + const tally: ImportTally = { + total: 0, + created: 0, + overwritten: 0, + skipped: 0, + failed: 0, + results: [], + }; + expect(formatImportNotice(tally)).toBe("Imported 0, skipped 0, failed 0"); + }); +}); diff --git a/__tests__/ui-shell.test.ts b/__tests__/ui-shell.test.ts new file mode 100644 index 0000000..507d1e9 --- /dev/null +++ b/__tests__/ui-shell.test.ts @@ -0,0 +1,157 @@ +// Tests for the pure helpers extracted from the UI shell, plus smoke checks +// that the DOM-wiring classes load. The Modal / SettingTab / FolderSuggest +// classes themselves are DOM wiring exercised by Obsidian at runtime; here we +// only assert they are defined (the `obsidian` import is stubbed by the jest +// mock). The real coverage is on the pure functions. + +import { defaultDestinationFolder, parseTagsInput, AddFeedModal } from "../add-feed-modal"; +import { + buildResolvedFeedFromConfig, + badgeStateForItem, + ImportModal, +} from "../import-modal"; +import { RssImporterSettingTab } from "../settings-tab"; +import { FolderSuggest } from "../folder-suggest"; +import { createStackedRow } from "../ui-helpers"; +import type { FeedConfig } from "../settings"; +import type { FeedItem } from "../feed-source"; +import type { ImportedRecord } from "../vault-index"; + +function makeFeed(overrides: Partial = {}): FeedConfig { + return { + feedId: "example.com", + sourceType: "generic", + feedUrl: "https://example.com/feed", + canonicalHost: "example.com", + publicationTitle: "Example", + author: "Jane", + destinationFolder: "Feeds/Example", + tags: ["news"], + tagNamespace: "", + importSourceTags: true, + enabled: true, + addedAt: "2026-06-14T00:00:00.000Z", + lastImportedAt: null, + ...overrides, + }; +} + +function makeItem(overrides: Partial = {}): FeedItem { + return { + sourceId: "example.com", + id: "item-1", + url: "https://example.com/p/item-1", + title: "First post", + author: "Jane", + publishedAt: "2026-06-01T00:00:00.000Z", + kind: "article", + contentHtml: "

body

", + isTruncated: false, + audience: "free", + tags: [], + section: null, + mediaUrl: null, + mediaType: null, + mediaBytes: null, + ...overrides, + }; +} + +describe("defaultDestinationFolder", () => { + test("joins parent and title with a single slash", () => { + expect(defaultDestinationFolder("Feeds", "My Blog")).toBe("Feeds/My Blog"); + }); + + test("trims surrounding slashes on both segments", () => { + expect(defaultDestinationFolder("/Feeds/", "/My Blog/")).toBe("Feeds/My Blog"); + }); + + test("collapses path separators in the title into spaces", () => { + expect(defaultDestinationFolder("Feeds", "a/b\\c")).toBe("Feeds/a b c"); + }); + + test("empty parent returns just the title", () => { + expect(defaultDestinationFolder("", "My Blog")).toBe("My Blog"); + }); + + test("empty title returns just the parent", () => { + expect(defaultDestinationFolder("Feeds", " ")).toBe("Feeds"); + }); +}); + +describe("parseTagsInput", () => { + test("splits, trims, and drops empties", () => { + expect(parseTagsInput(" a , b ,, c ")).toEqual(["a", "b", "c"]); + }); + + test("dedupes while preserving first-seen order", () => { + expect(parseTagsInput("a, b, a, c, b")).toEqual(["a", "b", "c"]); + }); + + test("empty input yields an empty list", () => { + expect(parseTagsInput("")).toEqual([]); + }); +}); + +describe("buildResolvedFeedFromConfig", () => { + test("projects the config's canonical metadata onto a ResolvedFeed", () => { + const feed = makeFeed(); + const resolved = buildResolvedFeedFromConfig(feed); + expect(resolved.feedId).toBe("example.com"); + expect(resolved.feedUrl).toBe("https://example.com/feed"); + expect(resolved.canonicalHost).toBe("example.com"); + expect(resolved.sourceType).toBe("generic"); + expect(resolved.publicationTitle).toBe("Example"); + expect(resolved.author).toBe("Jane"); + // Preview-only fields default to empty/unknown for listing. + expect(resolved.sampleTitles).toEqual([]); + expect(resolved.audienceHint).toBe("unknown"); + }); +}); + +describe("badgeStateForItem", () => { + const dismissedSet = new Set(); + const dismissStore = { + isDismissed: (_feedId: string, itemId: string): boolean => dismissedSet.has(itemId), + }; + + beforeEach(() => { + dismissedSet.clear(); + }); + + test("returns imported when the item is in the vault index", () => { + const item = makeItem({ id: "in-vault" }); + const index = new Map([["in-vault", { path: "Feeds/Example/x.md" }]]); + expect(badgeStateForItem(item, "example.com", index, dismissStore)).toBe("imported"); + }); + + test("imported wins over dismissed", () => { + const item = makeItem({ id: "both" }); + dismissedSet.add("both"); + const index = new Map([["both", { path: "Feeds/Example/x.md" }]]); + expect(badgeStateForItem(item, "example.com", index, dismissStore)).toBe("imported"); + }); + + test("returns dismissed when dismissed and not imported", () => { + const item = makeItem({ id: "skip-me" }); + dismissedSet.add("skip-me"); + const index = new Map(); + expect(badgeStateForItem(item, "example.com", index, dismissStore)).toBe("dismissed"); + }); + + test("returns available when neither imported nor dismissed", () => { + const item = makeItem({ id: "fresh" }); + const index = new Map(); + expect(badgeStateForItem(item, "example.com", index, dismissStore)).toBe("available"); + }); +}); + +describe("UI shell classes are defined", () => { + test("modal and tab classes load under the obsidian stub", () => { + expect(typeof AddFeedModal).toBe("function"); + expect(typeof ImportModal).toBe("function"); + expect(typeof RssImporterSettingTab).toBe("function"); + expect(typeof FolderSuggest).toBe("function"); + expect(typeof createStackedRow).toBe("function"); + }); +}); diff --git a/add-feed-modal.ts b/add-feed-modal.ts new file mode 100644 index 0000000..cb94ff6 --- /dev/null +++ b/add-feed-modal.ts @@ -0,0 +1,352 @@ +// Modal for adding a new feed. +// +// Flow: the user types a feed URL or Substack handle, clicks Resolve, and the +// modal classifies the input (handle/Substack -> SubstackFeedSource, else +// GenericRssFeedSource), calls source.resolve(input), and renders a preview +// card (publication title, canonical host, source-type badge, a few sample +// titles, free/paid hint). The destination folder defaults to +// `/` with folder autocomplete, and a +// tags field captures feed-level tags. Save is disabled until a successful +// resolve; on Save the modal builds a FeedConfig and hands it to the onSave +// callback supplied by the caller. + +import { App, Modal, Notice, Setting } from "obsidian"; +import type { FeedSource, ResolvedFeed } from "./feed-source"; +import type { FeedConfig, RssImporterSettings } from "./settings"; +import { createStackedRow } from "./ui-helpers"; +import { FolderSuggest } from "./folder-suggest"; + +export interface AddFeedModalDeps { + settings: RssImporterSettings; + /** Picks a source for an input. The integrator wires this to the real sources. */ + makeSource: (input: string) => { source: FeedSource }; + onSave: (feed: FeedConfig) => Promise; +} + +/** + * Build the default destination folder for a freshly resolved feed: the global + * parent folder joined with the publication title. Both segments are trimmed of + * surrounding slashes/whitespace so the join never produces a doubled or + * leading slash. Exported for unit testing. + */ +export function defaultDestinationFolder(parentFolder: string, publicationTitle: string): string { + const parent = parentFolder.trim().replace(/^\/+|\/+$/g, ""); + const title = publicationTitle.trim().replace(/[\\/]+/g, " ").replace(/^\/+|\/+$/g, "").trim(); + if (parent.length === 0) { + return title; + } + if (title.length === 0) { + return parent; + } + return `${parent}/${title}`; +} + +/** + * Split a comma-separated tags string into a clean list: trim each entry, drop + * empties, dedupe while preserving order. Exported for unit testing. + */ +export function parseTagsInput(raw: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const part of raw.split(",")) { + const tag = part.trim(); + if (tag.length === 0 || seen.has(tag)) { + continue; + } + seen.add(tag); + out.push(tag); + } + return out; +} + +export class AddFeedModal extends Modal { + private readonly deps: AddFeedModalDeps; + + private resolved: ResolvedFeed | null = null; + private source: FeedSource | null = null; + + private inputEl: HTMLInputElement | null = null; + private folderInputEl: HTMLInputElement | null = null; + private tagsInputEl: HTMLInputElement | null = null; + private previewEl: HTMLDivElement | null = null; + private saveButtonEl: HTMLButtonElement | null = null; + private folderEdited = false; + private focusTimer: number | null = null; + + constructor(app: App, deps: AddFeedModalDeps) { + super(app); + this.deps = deps; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("rss-importer-add-feed"); + this.setTitle("Add feed"); + + // Input plus Resolve button. Stacked so the input gets full width and + // the button sits beneath it. + const inputRow = createStackedRow(contentEl, { + name: "Feed URL or handle", + description: "A feed URL, a Substack subdomain or post URL, or an @handle.", + }); + const input = inputRow.content.createEl("input", { + cls: "rss-importer-text-input", + attr: { type: "text", placeholder: "Paste a feed URL or @handle" }, + }); + this.inputEl = input; + const resolveBtn = inputRow.content.createEl("button", { + cls: "rss-importer-resolve-button mod-cta", + text: "Resolve", + attr: { type: "button" }, + }); + resolveBtn.addEventListener("click", () => { + void this.resolveInput(); + }); + input.addEventListener("keydown", (evt: KeyboardEvent) => { + if (evt.key === "Enter") { + evt.preventDefault(); + void this.resolveInput(); + } + }); + + // Preview card (filled in after a successful resolve). + const previewRow = createStackedRow(contentEl, { + name: "Preview", + description: "Resolve the feed to see its details before saving.", + }); + this.previewEl = previewRow.content.createDiv({ cls: "rss-importer-preview" }); + this.renderPreviewPlaceholder(); + + // Destination folder with vault autocomplete. + const folderRow = createStackedRow(contentEl, { + name: "Destination folder", + description: "Where this feed's notes are saved.", + }); + const folderInput = folderRow.content.createEl("input", { + cls: "rss-importer-text-input", + attr: { type: "text", placeholder: this.deps.settings.defaultParentFolder }, + }); + this.folderInputEl = folderInput; + new FolderSuggest(this.app, folderInput); + folderInput.addEventListener("input", () => { + this.folderEdited = true; + }); + + // Feed-level tags. + const tagsRow = createStackedRow(contentEl, { + name: "Tags", + description: "Comma-separated tags applied to every note from this feed.", + }); + const tagsInput = tagsRow.content.createEl("input", { + cls: "rss-importer-text-input", + attr: { type: "text", placeholder: "Newsletter, reading" }, + }); + this.tagsInputEl = tagsInput; + + // Footer actions: Cancel and Save. Save stays disabled until resolved. + const footer = new Setting(contentEl); + footer.addButton((btn) => + btn.setButtonText("Cancel").onClick(() => { + this.close(); + }), + ); + footer.addButton((btn) => { + btn + .setButtonText("Save") + .setCta() + .setDisabled(true) + .onClick(() => { + void this.save(); + }); + this.saveButtonEl = btn.buttonEl; + }); + + this.focusTimer = window.setTimeout(() => { + this.focusTimer = null; + input.focus(); + }, 0); + } + + onClose(): void { + if (this.focusTimer !== null) { + window.clearTimeout(this.focusTimer); + this.focusTimer = null; + } + this.contentEl.empty(); + } + + // Classify the input, resolve it, and render the preview. All failures + // surface as a Notice plus a console.error; the modal stays open so the + // user can correct the input. + private async resolveInput(): Promise { + const input = this.inputEl?.value.trim() ?? ""; + if (input.length === 0) { + new Notice("Enter a feed URL or handle first."); + return; + } + this.setSaveDisabled(true); + this.renderPreviewLoading(); + try { + const { source } = this.deps.makeSource(input); + const resolved = await source.resolve(input); + this.source = source; + this.resolved = resolved; + this.renderPreviewCard(resolved); + this.seedFolderDefault(resolved); + this.setSaveDisabled(false); + } catch (err) { + this.resolved = null; + this.source = null; + this.renderPreviewError(); + new Notice("Could not resolve that feed. Check the URL or handle and try again."); + console.error(err); + } + } + + // Build a FeedConfig from the resolved feed plus the folder and tags inputs, + // then hand it to the caller. Guards against a Save click that races the + // resolve state. + private async save(): Promise { + if (this.resolved === null) { + new Notice("Resolve a feed before saving."); + return; + } + const resolved = this.resolved; + const destinationFolder = + this.folderInputEl?.value.trim() ?? + defaultDestinationFolder(this.deps.settings.defaultParentFolder, resolved.publicationTitle); + const tags = parseTagsInput(this.tagsInputEl?.value ?? ""); + + const feed: FeedConfig = { + feedId: resolved.feedId, + sourceType: resolved.sourceType, + feedUrl: resolved.feedUrl, + canonicalHost: resolved.canonicalHost, + publicationTitle: resolved.publicationTitle, + author: resolved.author, + destinationFolder: + destinationFolder.length === 0 + ? defaultDestinationFolder( + this.deps.settings.defaultParentFolder, + resolved.publicationTitle, + ) + : destinationFolder, + tags, + tagNamespace: "", + importSourceTags: true, + enabled: true, + addedAt: new Date().toISOString(), + lastImportedAt: null, + }; + + try { + await this.deps.onSave(feed); + new Notice(`Added feed ${resolved.publicationTitle}.`); + this.close(); + } catch (err) { + new Notice("Could not save the feed. See the console for details."); + console.error(err); + } + } + + private seedFolderDefault(resolved: ResolvedFeed): void { + if (this.folderInputEl === null || this.folderEdited) { + return; + } + this.folderInputEl.value = defaultDestinationFolder( + this.deps.settings.defaultParentFolder, + resolved.publicationTitle, + ); + } + + private setSaveDisabled(disabled: boolean): void { + if (this.saveButtonEl !== null) { + this.saveButtonEl.toggleAttribute("disabled", disabled); + } + } + + private renderPreviewPlaceholder(): void { + const el = this.previewEl; + if (el === null) { + return; + } + el.empty(); + el.addClass("is-empty"); + el.removeClass("is-error"); + el.createSpan({ + cls: "rss-importer-preview-hint", + text: "No feed resolved yet.", + }); + } + + private renderPreviewLoading(): void { + const el = this.previewEl; + if (el === null) { + return; + } + el.empty(); + el.removeClass("is-empty"); + el.removeClass("is-error"); + el.createSpan({ cls: "rss-importer-preview-hint", text: "Resolving feed…" }); + } + + private renderPreviewError(): void { + const el = this.previewEl; + if (el === null) { + return; + } + el.empty(); + el.removeClass("is-empty"); + el.addClass("is-error"); + el.createSpan({ + cls: "rss-importer-preview-hint", + text: "Could not resolve that feed.", + }); + } + + // Render the resolved feed's details into the preview card. + private renderPreviewCard(resolved: ResolvedFeed): void { + const el = this.previewEl; + if (el === null) { + return; + } + el.empty(); + el.removeClass("is-empty"); + el.removeClass("is-error"); + + const header = el.createDiv({ cls: "rss-importer-preview-header" }); + header.createSpan({ + cls: "rss-importer-preview-title", + text: resolved.publicationTitle.length > 0 ? resolved.publicationTitle : resolved.canonicalHost, + }); + header.createSpan({ + cls: `rss-importer-source-badge rss-importer-source-${resolved.sourceType}`, + text: resolved.sourceType === "substack" ? "Substack" : "RSS", + }); + + const meta = el.createDiv({ cls: "rss-importer-preview-meta" }); + meta.createSpan({ cls: "rss-importer-preview-host", text: resolved.canonicalHost }); + if (resolved.audienceHint !== "unknown") { + meta.createSpan({ + cls: `rss-importer-audience-badge rss-importer-audience-${resolved.audienceHint}`, + text: resolved.audienceHint === "paid" ? "Paid" : "Free", + }); + } + if (resolved.author !== null && resolved.author.length > 0) { + meta.createSpan({ cls: "rss-importer-preview-author", text: `by ${resolved.author}` }); + } + + if (resolved.sampleTitles.length > 0) { + const list = el.createEl("ul", { cls: "rss-importer-preview-samples" }); + for (const title of resolved.sampleTitles.slice(0, 5)) { + list.createEl("li", { text: title }); + } + } else { + el.createSpan({ + cls: "rss-importer-preview-hint", + text: "No recent items to preview.", + }); + } + } +} diff --git a/folder-suggest.ts b/folder-suggest.ts new file mode 100644 index 0000000..294df93 --- /dev/null +++ b/folder-suggest.ts @@ -0,0 +1,57 @@ +// Vault-folder autocomplete for a plain text input. +// +// AbstractInputSuggest renders a dropdown of suggestions beneath a text input +// and wires up keyboard/mouse selection for us. We supply the candidate list +// (every loaded folder, including the vault root as an empty string) filtered +// by a case-insensitive substring match on the query, plus how to render and +// how to commit a selection. Callers register an onSelect handler via the +// inherited `onSelect` API to react when the user picks a folder. + +import { AbstractInputSuggest, App, TFolder } from "obsidian"; + +export class FolderSuggest extends AbstractInputSuggest { + private readonly inputEl: HTMLInputElement; + + constructor(app: App, inputEl: HTMLInputElement) { + super(app, inputEl); + this.inputEl = inputEl; + } + + // Every folder path in the vault, including "" for the root, narrowed to + // the ones whose path contains the typed query (case-insensitive). An empty + // query returns the full list so the dropdown shows everything to start. + protected getSuggestions(query: string): string[] { + const lower = query.toLowerCase(); + const folders: string[] = []; + // Root is a valid destination but has no TFolder entry to iterate, so + // seed it explicitly. It matches an empty query and is filtered out of + // non-empty queries by the substring test below (since "" never + // contains a non-empty needle). + if (lower.length === 0) { + folders.push(""); + } + for (const file of this.app.vault.getAllLoadedFiles()) { + if (!(file instanceof TFolder)) { + continue; + } + const path = file.path; + if (path.toLowerCase().includes(lower)) { + folders.push(path); + } + } + return folders; + } + + renderSuggestion(folder: string, el: HTMLElement): void { + el.setText(folder.length === 0 ? "/" : folder); + } + + // Commit the chosen folder: write it into the input, fire an `input` event + // so any value-change listeners (and the inherited onSelect) observe it, + // then dismiss the dropdown. + selectSuggestion(folder: string): void { + this.inputEl.value = folder; + this.inputEl.dispatchEvent(new Event("input")); + this.close(); + } +} diff --git a/image-downloader.ts b/image-downloader.ts new file mode 100644 index 0000000..8d4ee4a --- /dev/null +++ b/image-downloader.ts @@ -0,0 +1,377 @@ +// Downloads remote images referenced by converted Markdown into the vault and +// rewrites the references to point at the local copies. +// +// This is the "download" half of the images-mode setting (the other mode, +// "link", leaves remote URLs untouched and never calls this module). It runs +// after HTML-to-Markdown conversion: every `![alt](url)` whose url is an +// absolute http(s) link is downloaded once, written under a per-feed image +// folder, and its reference rewritten to the vault-relative path. +// +// Robustness contract: +// - Best effort. A single image that fails to download (transport error, +// non-2xx, empty body, write error) leaves its ORIGINAL url in place and the +// conversion continues. One broken image never sinks the note. +// - Deterministic, deduplicated filenames. The same url within one call maps +// to one downloaded file; distinct urls that sanitize to the same base name +// are disambiguated with a numeric suffix. +// - downloadAndRewrite never throws. Folder-creation failure degrades to +// returning the markdown unchanged. +// +// No `obsidian` import and no DOM use: the vault surface is a structural +// interface so tests inject a plain object and main.ts passes app.vault. + +import type { FeedItem, HttpFetcher } from "./feed-source"; + +/** A vault file/folder handle the downloader only needs a path from. */ +export interface BinaryFileLike { + readonly path: string; +} + +/** + * The minimal vault surface the downloader needs. Obsidian's Vault satisfies + * this structurally (getFileByPath / getFolderByPath / createFolder return the + * concrete TFile/TFolder, which carry a `path`; createBinary writes bytes). + */ +export interface VaultBinaryLike { + getFileByPath(path: string): BinaryFileLike | null; + getFolderByPath(path: string): BinaryFileLike | null; + createFolder(path: string): Promise; + createBinary(path: string, data: ArrayBuffer): Promise; +} + +export interface ImageDownloaderOptions { + readonly fetcher: HttpFetcher; + readonly vault: VaultBinaryLike; +} + +// Matches a Markdown image: ![alt](url). The url group stops at the first +// closing paren or whitespace so a trailing `(width=...)` title or a following +// paren does not get swallowed. Alt text is captured but only used to rebuild +// the rewritten reference unchanged. +const IMAGE_REF = /!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+[^)]*)?\)/g; + +// Map common image content-types to a file extension when the URL has none. +const CONTENT_TYPE_EXT: Record = { + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "image/svg": "svg", + "image/bmp": "bmp", + "image/tiff": "tiff", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", + "image/avif": "avif", + "image/heic": "heic", +}; + +// Extensions we accept derived from a URL path. Anything else (or none) falls +// back to the content-type, then to a generic default. +const KNOWN_IMAGE_EXTS = new Set([ + "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", + "tiff", "tif", "ico", "avif", "heic", "heif", +]); + +const DEFAULT_EXT = "png"; + +export class ImageDownloader { + private readonly fetcher: HttpFetcher; + private readonly vault: VaultBinaryLike; + + constructor(opts: ImageDownloaderOptions) { + this.fetcher = opts.fetcher; + this.vault = opts.vault; + } + + /** + * Download every absolute http(s) image referenced by `markdown` into + * `folderPath`, then return the markdown with each successfully-downloaded + * reference rewritten to its vault-relative local path. Never throws: a + * per-image failure leaves that reference untouched; a folder-creation + * failure returns the input markdown unchanged. + */ + async downloadAndRewrite(markdown: string, folderPath: string): Promise { + const folder = normalizeFolderPath(folderPath); + + // Collect the distinct downloadable urls first. Resolving each url once + // keeps a url that appears multiple times mapped to a single download and + // a single rewrite target. + const urls = collectDownloadableUrls(markdown); + if (urls.length === 0) { + return markdown; + } + + try { + await this.ensureFolder(folder); + } catch (err) { + // Without the folder there is nowhere to write. Degrade to leaving + // every reference as-is rather than throwing out of the conversion. + console.error(err); + return markdown; + } + + // url -> vault-relative path, populated only for downloads that succeed. + const rewrites = new Map(); + // Track filenames used this call so distinct urls never collide on disk. + const usedNames = new Set(); + + for (const url of urls) { + try { + const localPath = await this.downloadOne(url, folder, usedNames); + if (localPath !== null) { + rewrites.set(url, localPath); + } + } catch (err) { + // Best effort: keep the original url and continue with the rest. + console.error(err); + } + } + + if (rewrites.size === 0) { + return markdown; + } + + return rewriteRefs(markdown, rewrites); + } + + /** + * Download one image and write it under `folder`. Returns the vault-relative + * path on success, or null when the image should be left as a remote url + * (non-2xx status, empty body). Throws only on an unexpected transport error, + * which the caller catches per-image. + */ + private async downloadOne( + url: string, + folder: string, + usedNames: Set, + ): Promise { + const response = await this.fetcher({ url, method: "GET" }); + if (response.status < 200 || response.status >= 300) { + return null; + } + const bytes = response.arrayBuffer; + if (bytes.byteLength === 0) { + return null; + } + + const contentType = headerValue(response.headers, "content-type"); + const ext = deriveExtension(url, contentType); + const baseName = deriveBaseName(url); + const fileName = uniqueFileName(baseName, ext, usedNames); + const targetPath = folder === "" ? fileName : `${folder}/${fileName}`; + + await this.vault.createBinary(targetPath, bytes); + return targetPath; + } + + /** + * Create the folder and each missing ancestor. createFolder throws when the + * folder already exists, so each segment is checked first. A segment that + * already exists as a file is left alone (the binary write will surface a + * clear error if the target conflicts). + */ + private async ensureFolder(folder: string): Promise { + if (folder === "") { + return; + } + const segments = folder.split("/"); + for (let i = 1; i <= segments.length; i++) { + const partial = segments.slice(0, i).join("/"); + if (this.vault.getFolderByPath(partial) === null) { + await this.vault.createFolder(partial); + } + } + } +} + +// ----------------------------------------------------------------------------- +// Pure helpers (kept module-private; exercised through downloadAndRewrite). +// ----------------------------------------------------------------------------- + +/** Read a header case-insensitively; returns "" when absent. */ +function headerValue(headers: Record, name: string): string { + const wanted = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === wanted) { + const value = headers[key]; + return value ?? ""; + } + } + return ""; +} + +/** True when a url is an absolute http(s) link we should try to download. */ +function isAbsoluteHttp(url: string): boolean { + return /^https?:\/\//i.test(url); +} + +/** + * Scan the markdown for image references and return the distinct absolute + * http(s) urls in first-seen order. First-seen order makes a multi-image note's + * download sequence deterministic, which keeps disambiguation suffixes stable. + */ +function collectDownloadableUrls(markdown: string): string[] { + const seen = new Set(); + const out: string[] = []; + IMAGE_REF.lastIndex = 0; + let match: RegExpExecArray | null = IMAGE_REF.exec(markdown); + while (match !== null) { + const url = match[2]; + if (url !== undefined && isAbsoluteHttp(url) && !seen.has(url)) { + seen.add(url); + out.push(url); + } + match = IMAGE_REF.exec(markdown); + } + return out; +} + +/** + * Rewrite every image reference whose url has a local replacement. The alt text + * and any title segment are preserved by reusing the captured alt and pointing + * the url at the local path. Urls without a replacement are left untouched. + */ +function rewriteRefs(markdown: string, rewrites: Map): string { + IMAGE_REF.lastIndex = 0; + return markdown.replace(IMAGE_REF, (whole, alt: string, url: string) => { + const local = rewrites.get(url); + if (local === undefined) { + return whole; + } + return `![${alt}](${local})`; + }); +} + +/** + * Derive a file extension. Prefer a known image extension from the URL path; + * otherwise map the content-type; otherwise fall back to a generic default so a + * file always gets a usable extension. + */ +function deriveExtension(url: string, contentType: string): string { + const fromUrl = extFromUrl(url); + if (fromUrl !== null) { + return fromUrl; + } + const ct = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; + const mapped = CONTENT_TYPE_EXT[ct]; + if (mapped !== undefined) { + return mapped; + } + return DEFAULT_EXT; +} + +/** Extract a known image extension from a URL path, or null. */ +function extFromUrl(url: string): string | null { + const path = urlPath(url); + const lastDot = path.lastIndexOf("."); + if (lastDot === -1 || lastDot === path.length - 1) { + return null; + } + const ext = path.slice(lastDot + 1).toLowerCase(); + return KNOWN_IMAGE_EXTS.has(ext) ? ext : null; +} + +/** + * The path portion of a url, stripped of query and fragment, without parsing + * via URL (which is not guaranteed everywhere this runs). Strips the scheme and + * authority so only the path text remains. + */ +function urlPath(url: string): string { + let rest = url.replace(/^https?:\/\//i, ""); + rest = rest.split("#")[0] ?? rest; + rest = rest.split("?")[0] ?? rest; + const slash = rest.indexOf("/"); + return slash === -1 ? "" : rest.slice(slash); +} + +/** + * Derive a sanitized base filename (without extension) from a url. Uses the last + * path segment when it has usable content, else a short hash of the url so two + * distinct extensionless urls do not both fall back to the same literal name. + */ +function deriveBaseName(url: string): string { + const path = urlPath(url); + const segments = path.split("/").filter((s) => s.length > 0); + const last = segments.length > 0 ? segments[segments.length - 1] : undefined; + let base = last ?? ""; + const dot = base.lastIndexOf("."); + if (dot > 0) { + base = base.slice(0, dot); + } + base = sanitizeBaseName(base); + if (base.length === 0) { + base = `image-${shortHash(url)}`; + } + return base; +} + +/** + * Sanitize a filename base: drop characters illegal on Windows/macOS/Linux and + * Obsidian's wikilink brackets, collapse whitespace and dot/space runs, clamp + * length. Returns "" when nothing usable remains so the caller can fall back. + */ +function sanitizeBaseName(name: string): string { + let out = name.trim().replace(/\s+/g, "-"); + // eslint-disable-next-line no-control-regex -- strip NUL and other non-whitespace control codes from the filename + out = out.replace(/[<>:"/\\|?*\x00-\x1f[\]]/g, "-"); + out = out.replace(/-+/g, "-"); + out = out.replace(/^[.\- ]+/, "").replace(/[.\- ]+$/, ""); + if (out.length > 100) { + out = out.slice(0, 100).replace(/[.\- ]+$/, ""); + } + return out; +} + +/** + * Produce a filename unique within this call. Tries `base.ext` first, then + * `base-1.ext`, `base-2.ext`, ... The chosen name is recorded so the next + * distinct url cannot reuse it. + */ +function uniqueFileName(base: string, ext: string, used: Set): string { + const candidate = `${base}.${ext}`; + if (!used.has(candidate.toLowerCase())) { + used.add(candidate.toLowerCase()); + return candidate; + } + for (let n = 1; ; n++) { + const next = `${base}-${n}.${ext}`; + if (!used.has(next.toLowerCase())) { + used.add(next.toLowerCase()); + return next; + } + } +} + +/** + * A short, stable, filesystem-safe hash of a string. Not cryptographic: it only + * needs to keep two distinct extensionless urls from colliding on the same + * fallback name. djb2 rendered base36. + */ +function shortHash(input: string): string { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0; + } + return hash.toString(36); +} + +/** + * Normalize a vault folder path: trim, drop leading/trailing slashes, collapse + * doubled slashes, drop `.` and `..` segments. `..` is dropped (not thrown on) + * because this is an internal best-effort path, not a user-facing destination. + */ +function normalizeFolderPath(folder: string): string { + const cleaned = folder + .trim() + .replace(/^\/+|\/+$/g, "") + .replace(/\/{2,}/g, "/"); + const segments = cleaned + .split("/") + .filter((s) => s !== "" && s !== "." && s !== ".."); + return segments.join("/"); +} + +/** Re-export FeedItem for callers that type a processImages adapter inline. */ +export type { FeedItem }; diff --git a/import-modal.ts b/import-modal.ts new file mode 100644 index 0000000..daf255d --- /dev/null +++ b/import-modal.ts @@ -0,0 +1,427 @@ +// Modal for importing items from one configured feed. +// +// On open it enumerates the feed's items (building a ResolvedFeed from the +// stored FeedConfig), scans the destination folder for already-imported items, +// and renders a row per item with a three-state badge: +// - imported: a note already exists for this item in the vault index. +// - dismissed: the user dismissed this item via the dismiss store. +// - available: neither of the above; the row gets a selection checkbox. +// Each row also offers Dismiss / Undismiss. The Import button runs the injected +// ImportRunner over the selected items, updating a live progress line, then +// shows the summary notice plus a per-item breakdown. The modal owns its abort +// flag and any timer, both reset on close. + +import { App, Modal, Notice, setIcon } from "obsidian"; +import type { FeedItem, ResolvedFeed } from "./feed-source"; +import type { FeedConfig, RssImporterSettings } from "./settings"; +import type { DismissStore } from "./dismiss-store"; +import type { ImportRunner, ImportProgress, ImportTally } from "./import-runner"; +import { formatImportNotice } from "./import-runner"; +import { buildFeedItemIndex } from "./vault-index"; +import type { ImportedRecord } from "./vault-index"; + +/** Soft cap on how many items to enumerate for the modal list. */ +const LIST_ITEM_LIMIT = 50; + +export type ItemBadgeState = "imported" | "dismissed" | "available"; + +export interface ImportModalDeps { + feed: FeedConfig; + source: import("./feed-source").FeedSource; + runner: ImportRunner; + settings: RssImporterSettings; + dismissStore: DismissStore; + onDone?: () => void; +} + +/** + * Reconstruct the minimal ResolvedFeed the source needs to enumerate items from + * a stored FeedConfig. The config already carries the canonical metadata that + * the original resolve captured, so this is a straight projection. Sample + * titles and the audience hint are preview-only and not needed for listing, so + * they default to empty/unknown. Exported for unit testing. + */ +export function buildResolvedFeedFromConfig(feed: FeedConfig): ResolvedFeed { + return { + sourceType: feed.sourceType, + feedId: feed.feedId, + canonicalHost: feed.canonicalHost, + feedUrl: feed.feedUrl, + publicationTitle: feed.publicationTitle, + author: feed.author, + sampleTitles: [], + audienceHint: "unknown", + }; +} + +/** + * Decide the badge state for one item. Imported wins over dismissed (a note + * already in the vault is the strongest signal), dismissed wins over available. + * Pure and exported so the three-state logic is unit-testable without the DOM. + */ +export function badgeStateForItem( + item: FeedItem, + feedId: string, + vaultIndex: ReadonlyMap, + dismissStore: Pick, +): ItemBadgeState { + if (vaultIndex.has(item.id)) { + return "imported"; + } + if (dismissStore.isDismissed(feedId, item.id)) { + return "dismissed"; + } + return "available"; +} + +interface ItemRow { + item: FeedItem; + rowEl: HTMLDivElement; + checkbox: HTMLInputElement | null; +} + +export class ImportModal extends Modal { + private readonly deps: ImportModalDeps; + + private aborted = false; + private importing = false; + private items: FeedItem[] = []; + private vaultIndex: Map = new Map(); + private readonly rows: ItemRow[] = []; + + private listEl: HTMLDivElement | null = null; + private progressEl: HTMLDivElement | null = null; + private summaryEl: HTMLDivElement | null = null; + private importButtonEl: HTMLButtonElement | null = null; + private focusTimer: number | null = null; + + constructor(app: App, deps: ImportModalDeps) { + super(app); + this.deps = deps; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("rss-importer-import-modal"); + this.setTitle(`Import from ${this.deps.feed.publicationTitle}`); + + this.listEl = contentEl.createDiv({ cls: "rss-importer-item-list" }); + this.renderListMessage("Loading items…"); + + this.progressEl = contentEl.createDiv({ cls: "rss-importer-progress" }); + this.summaryEl = contentEl.createDiv({ cls: "rss-importer-summary" }); + + const actions = contentEl.createDiv({ cls: "rss-importer-modal-actions" }); + const importBtn = actions.createEl("button", { + cls: "rss-importer-import-button mod-cta", + text: "Import selected", + attr: { type: "button" }, + }); + importBtn.toggleAttribute("disabled", true); + importBtn.addEventListener("click", () => { + void this.runImport(); + }); + this.importButtonEl = importBtn; + + const closeBtn = actions.createEl("button", { + cls: "rss-importer-close-button", + text: "Close", + attr: { type: "button" }, + }); + closeBtn.addEventListener("click", () => { + this.close(); + }); + + void this.loadItems(); + } + + onClose(): void { + this.aborted = true; + if (this.focusTimer !== null) { + window.clearTimeout(this.focusTimer); + this.focusTimer = null; + } + this.contentEl.empty(); + } + + // Enumerate the feed's items and build the vault index, then render the list. + // Any failure leaves a clear message in the list area plus a Notice. + private async loadItems(): Promise { + try { + const resolved = buildResolvedFeedFromConfig(this.deps.feed); + const items = await this.deps.source.listItems(resolved, { limit: LIST_ITEM_LIMIT }); + if (this.aborted) { + return; + } + this.items = items; + this.vaultIndex = buildFeedItemIndex(this.app, this.deps.feed.destinationFolder); + this.renderItems(); + } catch (err) { + this.renderListMessage("Could not load this feed's items."); + new Notice("Could not load this feed. See the console for details."); + console.error(err); + } + } + + private renderListMessage(message: string): void { + const el = this.listEl; + if (el === null) { + return; + } + el.empty(); + el.createDiv({ cls: "rss-importer-list-message", text: message }); + } + + // Render one row per item with its badge and per-row actions. + private renderItems(): void { + const el = this.listEl; + if (el === null) { + return; + } + el.empty(); + this.rows.length = 0; + + if (this.items.length === 0) { + this.renderListMessage("This feed has no items right now."); + this.refreshImportButton(); + return; + } + + for (const item of this.items) { + this.renderItemRow(el, item); + } + this.refreshImportButton(); + + this.focusTimer = window.setTimeout(() => { + this.focusTimer = null; + this.importButtonEl?.focus(); + }, 0); + } + + private renderItemRow(parent: HTMLElement, item: FeedItem): void { + const state = badgeStateForItem( + item, + this.deps.feed.feedId, + this.vaultIndex, + this.deps.dismissStore, + ); + + const rowEl = parent.createDiv({ cls: `rss-importer-item-row is-${state}` }); + + // Selection cell: a checkbox only for available items; a badge for + // imported/dismissed rows so the layout stays aligned. + const selectCell = rowEl.createDiv({ cls: "rss-importer-item-select" }); + let checkbox: HTMLInputElement | null = null; + if (state === "available") { + checkbox = selectCell.createEl("input", { + cls: "rss-importer-item-checkbox", + attr: { type: "checkbox" }, + }); + checkbox.addEventListener("change", () => { + this.refreshImportButton(); + }); + } + + const main = rowEl.createDiv({ cls: "rss-importer-item-main" }); + const titleEl = main.createDiv({ cls: "rss-importer-item-title" }); + titleEl.setText(item.title.length > 0 ? item.title : "(untitled)"); + + const metaEl = main.createDiv({ cls: "rss-importer-item-meta" }); + this.renderBadge(metaEl, state); + if (item.publishedAt !== null) { + metaEl.createSpan({ cls: "rss-importer-item-date", text: formatDate(item.publishedAt) }); + } + if (item.audience === "paid") { + metaEl.createSpan({ + cls: "rss-importer-audience-badge rss-importer-audience-paid", + text: "Paid", + }); + } + if (item.isTruncated) { + metaEl.createSpan({ cls: "rss-importer-item-truncated", text: "Teaser" }); + } + + const actions = rowEl.createDiv({ cls: "rss-importer-item-actions" }); + const dismissed = state === "dismissed"; + const dismissBtn = actions.createEl("button", { + cls: "rss-importer-item-dismiss", + text: dismissed ? "Undismiss" : "Dismiss", + attr: { type: "button" }, + }); + // Imported rows can still be dismissed/undismissed; the toggle keys off + // the current dismiss-store state rather than the rendered badge. + dismissBtn.addEventListener("click", () => { + void this.toggleDismiss(item); + }); + + this.rows.push({ item, rowEl, checkbox }); + } + + private renderBadge(parent: HTMLElement, state: ItemBadgeState): void { + const badge = parent.createSpan({ cls: `rss-importer-badge rss-importer-badge-${state}` }); + const icon = badge.createSpan({ cls: "rss-importer-badge-icon" }); + if (state === "imported") { + setIcon(icon, "check"); + badge.createSpan({ cls: "rss-importer-badge-text", text: "Imported" }); + } else if (state === "dismissed") { + setIcon(icon, "eye-off"); + badge.createSpan({ cls: "rss-importer-badge-text", text: "Dismissed" }); + } else { + setIcon(icon, "circle"); + badge.createSpan({ cls: "rss-importer-badge-text", text: "Available" }); + } + } + + // Toggle the item's dismissed state in the store, then re-render so its + // badge and selectability update. Re-reads the vault index is unnecessary + // (dismiss does not write notes), so we reuse the current one. + private async toggleDismiss(item: FeedItem): Promise { + const feedId = this.deps.feed.feedId; + try { + if (this.deps.dismissStore.isDismissed(feedId, item.id)) { + await this.deps.dismissStore.undismiss(feedId, item.id); + } else { + await this.deps.dismissStore.dismiss(feedId, item.id); + } + this.renderItems(); + } catch (err) { + new Notice("Could not update the dismissed state. See the console for details."); + console.error(err); + } + } + + private selectedItems(): FeedItem[] { + const selected: FeedItem[] = []; + for (const row of this.rows) { + if (row.checkbox !== null && row.checkbox.checked) { + selected.push(row.item); + } + } + return selected; + } + + private refreshImportButton(): void { + const btn = this.importButtonEl; + if (btn === null) { + return; + } + const count = this.selectedItems().length; + btn.toggleAttribute("disabled", this.importing || count === 0); + btn.setText(count > 0 ? `Import selected (${count})` : "Import selected"); + } + + // Run the import over the selected items. Disables the button for the + // duration, streams progress into the progress line, then renders the + // summary and rebuilds the vault index so newly imported rows flip to the + // imported badge. + private async runImport(): Promise { + if (this.importing) { + return; + } + const items = this.selectedItems(); + if (items.length === 0) { + new Notice("Select at least one item to import."); + return; + } + + this.importing = true; + this.aborted = false; + this.refreshImportButton(); + this.clearSummary(); + + try { + const tally = await this.deps.runner.run(items, { + feedTags: this.deps.feed.tags, + onProgress: (p: ImportProgress) => { + this.renderProgress(p); + }, + isAborted: () => this.aborted, + }); + this.clearProgress(); + new Notice(formatImportNotice(tally)); + this.renderSummary(tally); + + // Refresh the index and re-render so imported items lose their + // checkbox and gain the imported badge. + this.vaultIndex = buildFeedItemIndex(this.app, this.deps.feed.destinationFolder); + this.renderItems(); + this.deps.onDone?.(); + } catch (err) { + this.clearProgress(); + new Notice("The import failed. See the console for details."); + console.error(err); + } finally { + this.importing = false; + this.refreshImportButton(); + } + } + + private renderProgress(p: ImportProgress): void { + const el = this.progressEl; + if (el === null) { + return; + } + el.empty(); + el.addClass("is-active"); + const title = p.item.title.length > 0 ? p.item.title : "(untitled)"; + el.createSpan({ + cls: "rss-importer-progress-text", + text: `Importing ${p.index + 1} of ${p.total}: ${title}`, + }); + } + + private clearProgress(): void { + const el = this.progressEl; + if (el === null) { + return; + } + el.empty(); + el.removeClass("is-active"); + } + + private clearSummary(): void { + const el = this.summaryEl; + if (el === null) { + return; + } + el.empty(); + } + + private renderSummary(tally: ImportTally): void { + const el = this.summaryEl; + if (el === null) { + return; + } + el.empty(); + + const counts = el.createDiv({ cls: "rss-importer-summary-counts" }); + counts.createSpan({ + cls: "rss-importer-summary-line", + text: `${tally.created} created, ${tally.overwritten} overwritten, ${tally.skipped} skipped, ${tally.failed} failed`, + }); + + const list = el.createEl("ul", { cls: "rss-importer-summary-list" }); + for (const result of tally.results) { + const li = list.createEl("li", { cls: `rss-importer-summary-item is-${result.status}` }); + const title = result.item.title.length > 0 ? result.item.title : "(untitled)"; + li.createSpan({ cls: "rss-importer-summary-status", text: result.status }); + li.createSpan({ cls: "rss-importer-summary-title", text: title }); + if (result.reason !== null && result.reason.length > 0) { + li.createSpan({ cls: "rss-importer-summary-reason", text: result.reason }); + } + } + } +} + +// Render an ISO timestamp as a short YYYY-MM-DD label, or empty when it does +// not parse. Local to the modal: the note writer has its own UTC formatter for +// frontmatter; this is display-only. +function formatDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return ""; + } + const pad = (n: number): string => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} diff --git a/import-runner.ts b/import-runner.ts new file mode 100644 index 0000000..6cc2467 --- /dev/null +++ b/import-runner.ts @@ -0,0 +1,268 @@ +// Orchestrates a batch import: walk a list of normalized feed items, fetch each +// body, convert it to Markdown, optionally download images, and hand the result +// to the note writer. The runner is the single place that decides what a +// per-item failure means versus what aborts the whole batch. +// +// Failure policy (the contract this module exists to enforce): +// - A processImages failure must NOT fail the item. It is best-effort: we +// catch inside, log, and keep the un-rewritten Markdown. +// - Any other thrown error on an item is recorded as a 'failed' result with +// the error message as the reason, logged to the console, and the batch +// CONTINUES to the next item. One bad item never sinks the run. +// - A NoteWriterCancelledError is the single exception: the user cancelled a +// duplicate prompt, which means "stop the whole import". It propagates out +// of run() so the caller sees the cancellation explicitly. +// - opts.isAborted() is checked before each item; an external abort (user hit +// stop) ends the loop cleanly and returns the tally of work done so far. +// +// This module is pure orchestration over injected dependencies: it never +// imports `obsidian`, never touches the DOM, and is fully unit-testable with +// plain-object stubs for the source, note writer, and converter. + +import type { FeedItem } from "./feed-source"; +import type { NoteWriter } from "./note-writer"; +import { NoteWriterCancelledError } from "./note-writer"; +import type { DebugLogger } from "./debug-logger"; + +/** Outcome of importing a single feed item. */ +export interface ImportItemResult { + item: FeedItem; + status: "created" | "overwritten" | "skipped" | "failed"; + /** Vault-relative path written, or null when the item failed before a write. */ + path: string | null; + /** Failure reason when status is 'failed', else null. */ + reason: string | null; +} + +/** Aggregate result of a batch import: per-status counts plus the per-item log. */ +export interface ImportTally { + total: number; + created: number; + overwritten: number; + skipped: number; + failed: number; + results: ImportItemResult[]; +} + +/** Progress callback payload, emitted after each item is processed. */ +export interface ImportProgress { + /** Zero-based index of the item just processed. */ + index: number; + total: number; + /** The fully-fetched item (body populated), as written/attempted. */ + item: FeedItem; +} + +/** + * Dependency surface for the runner. Everything the runner needs is injected so + * it can be unit-tested with stubs and so main.ts owns the concrete wiring. + */ +export interface ImportRunnerDeps { + source: FeedSourceLike; + /** Pre-built for the target feed (destination folder, name template, policy). */ + noteWriter: NoteWriter; + /** ./html-converter convertHtmlToMarkdown. */ + convert: (html: string) => string; + /** Optional best-effort image download + markdown rewrite. */ + processImages?: (markdown: string, item: FeedItem) => Promise; + /** Optional debug logger; defaults to a no-op when absent. */ + debugLogger?: DebugLogger; +} + +/** + * The slice of FeedSource the runner actually consumes. Narrowed to fetchBody so + * tests inject a one-method stub and the runner does not depend on resolve / + * listItems. The concrete FeedSource satisfies this structurally. + */ +export interface FeedSourceLike { + fetchBody(item: FeedItem): Promise; +} + +/** Options for a single run() call. */ +export interface ImportRunOptions { + /** Feed-level tags applied to every note, merged with each item's own tags. */ + feedTags?: string[]; + /** Invoked after each item is processed (success or failure). */ + onProgress?: (progress: ImportProgress) => void; + /** Polled before each item; returning true ends the loop cleanly. */ + isAborted?: () => boolean; +} + +export class ImportRunner { + private readonly source: FeedSourceLike; + private readonly noteWriter: NoteWriter; + private readonly convert: (html: string) => string; + private readonly processImages?: ( + markdown: string, + item: FeedItem, + ) => Promise; + private readonly debug?: DebugLogger; + + constructor(deps: ImportRunnerDeps) { + this.source = deps.source; + this.noteWriter = deps.noteWriter; + this.convert = deps.convert; + this.processImages = deps.processImages; + this.debug = deps.debugLogger; + } + + async run( + items: readonly FeedItem[], + opts: ImportRunOptions, + ): Promise { + const tally: ImportTally = { + total: items.length, + created: 0, + overwritten: 0, + skipped: 0, + failed: 0, + results: [], + }; + + for (let index = 0; index < items.length; index++) { + // Check abort BEFORE doing any work for this item, so an abort + // requested mid-run stops cleanly without starting the next fetch. + if (opts.isAborted?.() === true) { + this.debug?.log({ + kind: "note", + message: `Import aborted before item ${index + 1} of ${items.length}`, + }); + break; + } + + const item = items[index]; + if (item === undefined) { + // Defensive: indexed access is guarded so the loop body never + // operates on undefined even if the array is sparse. + continue; + } + + const result = await this.importOne(item, opts.feedTags); + tally.results.push(result); + this.recordStatus(tally, result.status); + + // onProgress reports the fetched item when we have it. importOne + // returns the item it actually operated on (the fetched one on + // success, the original on an early fetch failure). + opts.onProgress?.({ + index, + total: items.length, + item: result.item, + }); + } + + return tally; + } + + /** + * Import a single item end to end. Returns an ImportItemResult; never throws + * for an ordinary per-item failure. The ONLY error it rethrows is + * NoteWriterCancelledError, which signals a user-requested abort of the whole + * batch and must escape run(). + */ + private async importOne( + item: FeedItem, + feedTags: string[] | undefined, + ): Promise { + // Track the item we will report progress for. On a successful fetch this + // becomes the body-populated item; on a fetch failure it stays the + // original so the caller still gets a sensible item reference. + let reported: FeedItem = item; + try { + const full = await this.source.fetchBody(item); + reported = full; + + const html = full.contentHtml ?? ""; + let markdown = this.convert(html); + + if (this.processImages !== undefined) { + try { + markdown = await this.processImages(markdown, full); + } catch (err) { + // Best-effort: an image pass failure must not fail the item. + // Keep the converted markdown as-is and log the detail. + console.error(err); + this.debug?.log({ + kind: "error", + message: `Image processing failed for "${full.title}", keeping converted text`, + payload: describeError(err), + }); + } + } + + const outcome = await this.noteWriter.writeNote(full, markdown, { + feedTags, + }); + this.debug?.log({ + kind: "note", + message: `${outcome.status} ${outcome.path}`, + endpoint: full.url, + }); + return { + item: full, + status: outcome.status, + path: outcome.path, + reason: null, + }; + } catch (err) { + // A user cancellation from the duplicate prompt aborts the whole run. + // Re-throw so run() unwinds and the caller can report the cancel. + if (err instanceof NoteWriterCancelledError) { + throw err; + } + const reason = describeError(err); + console.error(err); + this.debug?.log({ + kind: "error", + message: `Failed to import "${reported.title}": ${reason}`, + endpoint: reported.url, + }); + return { + item: reported, + status: "failed", + path: null, + reason, + }; + } + } + + /** Bump the matching counter for a recorded item status. */ + private recordStatus( + tally: ImportTally, + status: ImportItemResult["status"], + ): void { + switch (status) { + case "created": + tally.created++; + break; + case "overwritten": + tally.overwritten++; + break; + case "skipped": + tally.skipped++; + break; + case "failed": + tally.failed++; + break; + } + } +} + +/** Render a caught unknown into a non-empty human message. */ +function describeError(err: unknown): string { + if (err instanceof Error) { + return err.message.length > 0 ? err.message : err.name; + } + return String(err); +} + +/** + * One-line human summary of a completed import for a Notice. Counts that are + * zero are still shown so the message reads consistently across runs (e.g. + * "Imported 3, skipped 1, failed 0"). "Imported" folds created and overwritten + * together because both produced a note on disk. + */ +export function formatImportNotice(tally: ImportTally): string { + const imported = tally.created + tally.overwritten; + return `Imported ${imported}, skipped ${tally.skipped}, failed ${tally.failed}`; +} diff --git a/settings-tab.ts b/settings-tab.ts new file mode 100644 index 0000000..e063db8 --- /dev/null +++ b/settings-tab.ts @@ -0,0 +1,270 @@ +// Settings tab for RSS Importer, built on the declarative getSettingDefinitions +// API (Obsidian 1.13.0+). The tab has two sections: +// - A "Feeds" list: each configured feed is a navigable page (edit metadata +// or remove it); the add affordance opens the AddFeedModal. +// - A "Defaults" group: the global controls each feed falls back to, bound by +// key to the plugin's settings store via getControlValue/setControlValue. +// Heading words banned by the scorecard ("settings", "options", "general", and +// the plugin name) are deliberately avoided. + +import { + App, + Notice, + PluginSettingTab, + Setting, + SettingPage, + type Plugin, + type SettingDefinitionItem, +} from "obsidian"; +import type { FeedSource } from "./feed-source"; +import type { FeedConfig, RssImporterSettings } from "./settings"; +import { REQUEST_DELAY_MIN, REQUEST_DELAY_MAX } from "./settings"; +import { AddFeedModal } from "./add-feed-modal"; + +/** + * The minimal plugin surface this tab and the modals it opens need. Declared + * structurally rather than importing the concrete plugin class from main.ts so + * the UI shell does not create an import cycle. The real plugin satisfies this. + */ +export interface RssImporterPluginLike extends Plugin { + settings: RssImporterSettings; + saveSettings(): Promise; + /** Picks a source for a user-supplied feed input (wired in main.ts). */ + makeSource(input: string): { source: FeedSource }; +} + +export class RssImporterSettingTab extends PluginSettingTab { + private readonly typedPlugin: RssImporterPluginLike; + + constructor(app: App, plugin: RssImporterPluginLike) { + super(app, plugin); + this.typedPlugin = plugin; + } + + getSettingDefinitions(): SettingDefinitionItem[] { + const feeds = this.typedPlugin.settings.feeds; + return [ + { + type: "list", + heading: "Feeds", + emptyState: "No feeds yet. Add one to start importing.", + onDelete: (index: number) => { + this.removeFeed(index); + }, + addItem: { + name: "Add feed", + action: () => { + this.openAddFeed(); + }, + }, + items: feeds.map((feed) => ({ + type: "page" as const, + name: feed.publicationTitle.length > 0 ? feed.publicationTitle : feed.canonicalHost, + desc: feed.enabled ? feed.destinationFolder : `(disabled) ${feed.destinationFolder}`, + page: () => new FeedEditorPage(this, feed), + })), + }, + { + type: "group", + heading: "Defaults", + items: [ + { + name: "Show ribbon icon", + desc: "Add a left-ribbon button that opens the importer.", + control: { type: "toggle", key: "showRibbonIcon" }, + }, + { + name: "Duplicate handling", + desc: "What to do when a note already exists for an item.", + control: { + type: "dropdown", + key: "duplicatePolicy", + options: { + skip: "Skip", + overwrite: "Overwrite", + prompt: "Ask each time", + }, + }, + }, + { + name: "Request delay", + desc: "Pause between feed requests, in milliseconds.", + control: { + type: "slider", + key: "requestDelayMs", + min: REQUEST_DELAY_MIN, + max: REQUEST_DELAY_MAX, + step: 100, + }, + }, + { + name: "Images", + desc: "Link to remote images, or download them into the vault.", + control: { + type: "dropdown", + key: "imagesMode", + options: { + link: "Link to remote", + download: "Download into vault", + }, + }, + }, + { + name: "Parent folder", + desc: "Default parent folder for new feeds.", + control: { type: "folder", key: "defaultParentFolder" }, + }, + { + name: "Note name template", + desc: "Tokens: {{date}}, {{title}}, {{slug}}.", + control: { type: "text", key: "noteNameTemplate" }, + }, + { + name: "Verbose logging", + desc: "Write detailed import logs to the developer console.", + control: { type: "toggle", key: "debug" }, + }, + ], + }, + ]; + } + + // Binds declarative control reads to the plugin's own settings store. The + // `as unknown as Record` cast is the controlled pattern the + // reference plugin uses for the settings store; keys come only from the + // control definitions above. + getControlValue(key: string): unknown { + return (this.typedPlugin.settings as unknown as Record)[key]; + } + + async setControlValue(key: string, value: unknown): Promise { + (this.typedPlugin.settings as unknown as Record)[key] = value; + await this.typedPlugin.saveSettings(); + this.refreshDomState(); + } + + private openAddFeed(): void { + new AddFeedModal(this.app, { + settings: this.typedPlugin.settings, + makeSource: (input: string) => this.typedPlugin.makeSource(input), + onSave: async (feed: FeedConfig) => { + this.typedPlugin.settings.feeds.push(feed); + await this.typedPlugin.saveSettings(); + this.update(); + }, + }).open(); + } + + private removeFeed(index: number): void { + const feeds = this.typedPlugin.settings.feeds; + if (index < 0 || index >= feeds.length) { + return; + } + feeds.splice(index, 1); + void this.typedPlugin.saveSettings(); + this.update(); + } + + /** Exposed for the feed editor page to reach the plugin instance. */ + get plugin(): RssImporterPluginLike { + return this.typedPlugin; + } +} + +// A navigable sub-page for editing one feed's metadata. SettingPage.display() +// (not the deprecated PluginSettingTab.display()) is the supported way to render +// an imperative sub-page opened from a declarative list item. +class FeedEditorPage extends SettingPage { + private readonly tab: RssImporterSettingTab; + private readonly feed: FeedConfig; + + constructor(tab: RssImporterSettingTab, feed: FeedConfig) { + super(); + this.tab = tab; + this.feed = feed; + this.title = feed.publicationTitle.length > 0 ? feed.publicationTitle : feed.canonicalHost; + } + + private get plugin(): RssImporterPluginLike { + return this.tab.plugin; + } + + display(): void { + const feed = this.feed; + const editor = this.containerEl; + editor.empty(); + + new Setting(editor) + .setName("Enabled") + .setDesc("Turn importing for this feed on or off.") + .addToggle((toggle) => + toggle.setValue(feed.enabled).onChange(async (value) => { + feed.enabled = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(editor) + .setName("Title") + .setDesc("Display name for this feed.") + .addText((text) => + text.setValue(feed.publicationTitle).onChange(async (value) => { + feed.publicationTitle = value; + this.title = value.length > 0 ? value : feed.canonicalHost; + await this.plugin.saveSettings(); + }), + ); + + new Setting(editor) + .setName("Destination folder") + .setDesc("Where this feed's notes are saved.") + .addText((text) => + text.setValue(feed.destinationFolder).onChange(async (value) => { + feed.destinationFolder = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(editor) + .setName("Tags") + .setDesc("Comma-separated tags applied to every note from this feed.") + .addText((text) => + text.setValue(feed.tags.join(", ")).onChange(async (value) => { + feed.tags = value + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + await this.plugin.saveSettings(); + }), + ); + + new Setting(editor) + .setName("Pull item tags") + .setDesc("Also import each item's own tags from the source feed.") + .addToggle((toggle) => + toggle.setValue(feed.importSourceTags).onChange(async (value) => { + feed.importSourceTags = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(editor) + .setName("Remove feed") + .setDesc("Delete this feed. Imported notes are left in place.") + .addButton((btn) => + btn + .setButtonText("Remove") + .setDestructive() + .onClick(async () => { + const feeds = this.plugin.settings.feeds; + const index = feeds.indexOf(feed); + if (index >= 0) { + feeds.splice(index, 1); + await this.plugin.saveSettings(); + new Notice(`Removed feed ${this.title}.`); + this.tab.update(); + } + }), + ); + } +} diff --git a/styles.css b/styles.css index 54f682e..13df167 100644 --- a/styles.css +++ b/styles.css @@ -1 +1,353 @@ /* RSS Importer styles. Selectors use Obsidian theme variables; no inline styles, no !important. */ + +/* -------------------------------------------------------------------------- */ +/* Stacked rows: label and description on top, control area full-width below. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-stacked-row { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + padding: var(--size-4-3) 0; + border-top: 1px solid var(--background-modifier-border); +} + +.rss-importer-stacked-row:first-child { + border-top: none; +} + +.rss-importer-stacked-labels { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.rss-importer-stacked-name { + font-weight: var(--font-semibold); + color: var(--text-normal); +} + +.rss-importer-stacked-desc { + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.rss-importer-stacked-content { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + width: 100%; +} + +/* -------------------------------------------------------------------------- */ +/* Shared form controls. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-text-input { + width: 100%; +} + +.rss-importer-resolve-button { + align-self: flex-start; +} + +/* -------------------------------------------------------------------------- */ +/* Add-feed preview card. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-preview { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + padding: var(--size-4-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--background-secondary); +} + +.rss-importer-preview.is-empty, +.rss-importer-preview.is-error { + color: var(--text-muted); +} + +.rss-importer-preview.is-error { + border-color: var(--text-error); + color: var(--text-error); +} + +.rss-importer-preview-hint { + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.rss-importer-preview-header { + display: flex; + align-items: center; + gap: var(--size-4-2); + flex-wrap: wrap; +} + +.rss-importer-preview-title { + font-weight: var(--font-semibold); + color: var(--text-normal); +} + +.rss-importer-preview-meta { + display: flex; + align-items: center; + gap: var(--size-4-2); + flex-wrap: wrap; + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.rss-importer-preview-samples { + margin: 0; + padding-left: var(--size-4-4); + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.rss-importer-preview-samples li { + margin: var(--size-2-1) 0; +} + +/* -------------------------------------------------------------------------- */ +/* Source and audience badges. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-source-badge, +.rss-importer-audience-badge { + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); + line-height: 1; +} + +.rss-importer-source-substack { + background-color: var(--color-orange); + color: var(--text-on-accent); +} + +.rss-importer-source-generic { + background-color: var(--background-modifier-border); + color: var(--text-normal); +} + +.rss-importer-audience-free { + background-color: var(--color-green); + color: var(--text-on-accent); +} + +.rss-importer-audience-paid { + background-color: var(--color-yellow); + color: var(--text-on-accent); +} + +/* -------------------------------------------------------------------------- */ +/* Import modal: item list and rows. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-item-list { + display: flex; + flex-direction: column; + max-height: 50vh; + overflow-y: auto; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + margin-bottom: var(--size-4-3); +} + +.rss-importer-list-message { + padding: var(--size-4-4); + text-align: center; + color: var(--text-muted); +} + +.rss-importer-item-row { + display: flex; + align-items: flex-start; + gap: var(--size-4-2); + padding: var(--size-4-2) var(--size-4-3); + border-top: 1px solid var(--background-modifier-border); +} + +.rss-importer-item-row:first-child { + border-top: none; +} + +.rss-importer-item-row.is-imported, +.rss-importer-item-row.is-dismissed { + color: var(--text-muted); +} + +.rss-importer-item-select { + display: flex; + align-items: center; + min-width: var(--size-4-4); + padding-top: var(--size-2-1); +} + +.rss-importer-item-main { + display: flex; + flex-direction: column; + gap: var(--size-2-2); + flex: 1 1 auto; + min-width: 0; +} + +.rss-importer-item-title { + color: var(--text-normal); + font-weight: var(--font-medium); + overflow-wrap: anywhere; +} + +.rss-importer-item-row.is-imported .rss-importer-item-title, +.rss-importer-item-row.is-dismissed .rss-importer-item-title { + color: var(--text-muted); +} + +.rss-importer-item-meta { + display: flex; + align-items: center; + gap: var(--size-4-2); + flex-wrap: wrap; + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.rss-importer-item-truncated { + color: var(--text-warning); +} + +.rss-importer-item-actions { + display: flex; + align-items: center; + flex: 0 0 auto; +} + +/* -------------------------------------------------------------------------- */ +/* Three-state badges. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-badge { + display: inline-flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + font-size: var(--font-ui-smaller); + line-height: 1; +} + +.rss-importer-badge-icon { + display: inline-flex; + align-items: center; +} + +.rss-importer-badge-icon .svg-icon { + width: var(--icon-xs); + height: var(--icon-xs); +} + +.rss-importer-badge-imported { + background-color: var(--background-modifier-success); + color: var(--text-on-accent); +} + +.rss-importer-badge-dismissed { + background-color: var(--background-modifier-border); + color: var(--text-muted); +} + +.rss-importer-badge-available { + background-color: var(--background-secondary-alt); + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- */ +/* Progress line and summary. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-progress { + min-height: 0; +} + +.rss-importer-progress.is-active { + min-height: var(--size-4-6); + padding: var(--size-4-2) 0; + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.rss-importer-summary { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.rss-importer-summary-counts { + font-weight: var(--font-medium); + color: var(--text-normal); +} + +.rss-importer-summary-list { + margin: 0; + padding-left: 0; + list-style: none; + max-height: 30vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} + +.rss-importer-summary-item { + display: flex; + align-items: baseline; + gap: var(--size-4-2); + font-size: var(--font-ui-smaller); +} + +.rss-importer-summary-status { + flex: 0 0 auto; + min-width: var(--size-4-12); + font-variant: small-caps; + color: var(--text-muted); +} + +.rss-importer-summary-item.is-created .rss-importer-summary-status { + color: var(--text-success); +} + +.rss-importer-summary-item.is-overwritten .rss-importer-summary-status { + color: var(--text-accent); +} + +.rss-importer-summary-item.is-failed .rss-importer-summary-status { + color: var(--text-error); +} + +.rss-importer-summary-title { + flex: 1 1 auto; + min-width: 0; + color: var(--text-normal); + overflow-wrap: anywhere; +} + +.rss-importer-summary-reason { + flex: 0 0 auto; + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- */ +/* Modal action rows. */ +/* -------------------------------------------------------------------------- */ + +.rss-importer-modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--size-4-2); + margin-top: var(--size-4-3); +} diff --git a/ui-helpers.ts b/ui-helpers.ts new file mode 100644 index 0000000..90b0761 --- /dev/null +++ b/ui-helpers.ts @@ -0,0 +1,35 @@ +// Shared UI building blocks used by the modals and the settings tab. +// +// The "stacked row" convention (label and description on top, control area +// full-width below) is centralized here so it is reused consistently instead +// of fighting Obsidian's right-rail Setting widget for long controls like +// textareas and multi-control composite rows. Ported from the annoteca +// plugin's ui-helpers.ts with the CSS prefix changed to `rss-importer-`. + +export interface StackedRowOpts { + name: string; + description?: string; + cls?: string; +} + +export interface StackedRow { + row: HTMLDivElement; + content: HTMLDivElement; +} + +// Build a stacked row: title plus optional description on top, a content area +// below for full-width controls. Use this for textareas, multi-control +// composite rows (preview cards, folder plus tags forms), and anything else +// that does not fit comfortably in Obsidian's Setting right-rail layout. +export function createStackedRow(parent: HTMLElement, opts: StackedRowOpts): StackedRow { + const row = parent.createDiv({ + cls: `rss-importer-stacked-row${opts.cls ? " " + opts.cls : ""}`, + }); + const labels = row.createDiv({ cls: "rss-importer-stacked-labels" }); + labels.createDiv({ cls: "rss-importer-stacked-name", text: opts.name }); + if (opts.description) { + labels.createDiv({ cls: "rss-importer-stacked-desc", text: opts.description }); + } + const content = row.createDiv({ cls: "rss-importer-stacked-content" }); + return { row, content }; +}