Wave D: import services and UI shell

- import-runner: per-item orchestration (fetchBody, convert, optional image
  download, write); per-item failures never abort the batch; abort + progress;
  formatImportNotice. image-downloader: best-effort download + markdown rewrite.
- UI: createStackedRow helper, FolderSuggest (AbstractInputSuggest), AddFeedModal
  (resolve + preview + commit), ImportModal (three-state list, live progress,
  summary), declarative RssImporterSettingTab (Feeds list + Defaults group),
  real styles.css.
- Extend the obsidian test mock with SettingPage + settings-tab methods.
This commit is contained in:
Charles Kelsoe 2026-06-14 15:01:59 -04:00
parent 3b6b74a910
commit a2d53fc070
12 changed files with 2939 additions and 0 deletions

View file

@ -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 {

View file

@ -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<string, string>;
body?: ArrayBuffer;
throws?: Error;
}
/** Fetcher stub keyed by url; records the urls it was asked for. */
function makeFetcher(map: Record<string, FetchEntry>): {
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<string>();
folders = new Set<string>();
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<unknown> {
this.folders.add(path);
this.createdFolders.push(path);
return Promise.resolve({ path });
}
createBinary(path: string, data: ArrayBuffer): Promise<BinaryFileLike> {
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<unknown> =>
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();
});
});

View file

@ -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> = {}): 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<string, string | Error>,
): { source: FeedSourceLike; fetched: string[] } {
const fetched: string[] = [];
const source: FeedSourceLike = {
fetchBody(item: FeedItem): Promise<FeedItem> {
fetched.push(item.id);
const entry = bodies[item.id];
if (entry instanceof Error) {
return Promise.reject(entry);
}
return Promise.resolve({ ...item, contentHtml: entry ?? "<p>x</p>" });
},
};
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<WriteOutcome>,
): { writer: NoteWriter; calls: Array<{ id: string; body: string }> } {
const calls: Array<{ id: string; body: string }> = [];
const stub = {
writeNote(item: FeedItem, body: string): Promise<WriteOutcome> {
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: "<p>A</p>", b: "<p>B</p>", c: "<p>C</p>" });
const statuses: Record<string, WriteOutcome> = {
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: "<p>hello</p>" });
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:<p>hello</p>");
});
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: "<p>A</p>",
boom: new Error("fetch exploded"),
c: "<p>C</p>",
});
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: "<p>A</p>" });
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: "<p>A</p>", b: "<p>B</p>", c: "<p>C</p>" });
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: "<p>A</p>", b: "<p>B</p>", c: "<p>C</p>" });
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: "<p>A</p>", b: "<p>B</p>" });
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("<p>A</p>");
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: "<p>A</p>" });
const { writer, calls } = makeWriter(() =>
Promise.resolve({ status: "created", path: "Feeds/a.md" }),
);
const processImages = (): Promise<string> =>
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:<p>A</p>");
});
it("uses the processImages result when it succeeds", async () => {
const items = [makeItem({ id: "a" })];
const { source } = makeSource({ a: "<p>A</p>" });
const { writer, calls } = makeWriter(() =>
Promise.resolve({ status: "created", path: "Feeds/a.md" }),
);
const processImages = (md: string): Promise<string> =>
Promise.resolve(`${md} +imgs`);
const runner = new ImportRunner({
source,
noteWriter: writer,
convert: identityConvert,
processImages,
});
await runner.run(items, {});
expect(calls[0]?.body).toBe("MD:<p>A</p> +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<ImportTally>({
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");
});
});

157
__tests__/ui-shell.test.ts Normal file
View file

@ -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> = {}): 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> = {}): 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: "<p>body</p>",
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<string>();
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<string, ImportedRecord>([["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<string, ImportedRecord>([["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<string, ImportedRecord>();
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<string, ImportedRecord>();
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");
});
});

352
add-feed-modal.ts Normal file
View file

@ -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
// `<defaultParentFolder>/<publicationTitle>` 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<void>;
}
/**
* 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<string>();
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<void> {
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<void> {
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.",
});
}
}
}

57
folder-suggest.ts Normal file
View file

@ -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<string> {
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();
}
}

377
image-downloader.ts Normal file
View file

@ -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<unknown>;
createBinary(path: string, data: ArrayBuffer): Promise<BinaryFileLike>;
}
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<string, string> = {
"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<string> {
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<string, string>();
// Track filenames used this call so distinct urls never collide on disk.
const usedNames = new Set<string>();
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<string>,
): Promise<string | null> {
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<void> {
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<string, string>, 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<string>();
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, string>): 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>): 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 };

427
import-modal.ts Normal file
View file

@ -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<string, ImportedRecord>,
dismissStore: Pick<DismissStore, "isDismissed">,
): 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<string, ImportedRecord> = 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<void> {
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<void> {
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<void> {
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())}`;
}

268
import-runner.ts Normal file
View file

@ -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<string>;
/** 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<FeedItem>;
}
/** 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<string>;
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<ImportTally> {
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<ImportItemResult> {
// 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}`;
}

270
settings-tab.ts Normal file
View file

@ -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<void>;
/** 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<string, unknown>` 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<string, unknown>)[key];
}
async setControlValue(key: string, value: unknown): Promise<void> {
(this.typedPlugin.settings as unknown as Record<string, unknown>)[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();
}
}),
);
}
}

View file

@ -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);
}

35
ui-helpers.ts Normal file
View file

@ -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 };
}