mirror of
https://github.com/ckelsoe/obsidian-rss-importer.git
synced 2026-07-22 07:48:56 +00:00
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:
parent
3b6b74a910
commit
a2d53fc070
12 changed files with 2939 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
268
__tests__/image-downloader.test.ts
Normal file
268
__tests__/image-downloader.test.ts
Normal 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\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("");
|
||||
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(``, "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 = `\n\n`;
|
||||
const out = await dl.downloadAndRewrite(md, "images");
|
||||
|
||||
// Good one is rewritten; bad one keeps its remote url verbatim.
|
||||
expect(out).toContain("");
|
||||
expect(out).toContain(``);
|
||||
// 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(``, "images");
|
||||
|
||||
expect(out).toBe(``);
|
||||
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(``, "images");
|
||||
|
||||
expect(out).toBe(``);
|
||||
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(``, "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 = `\n\n`;
|
||||
const out = await dl.downloadAndRewrite(md, "images");
|
||||
|
||||
expect(requested).toEqual([url]);
|
||||
expect(vault.binaries).toHaveLength(1);
|
||||
expect(out).toContain("");
|
||||
expect(out).toContain("");
|
||||
});
|
||||
|
||||
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(` `, "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 = [
|
||||
"",
|
||||
"",
|
||||
``,
|
||||
].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("");
|
||||
expect(out).toContain("");
|
||||
expect(out).toContain("");
|
||||
});
|
||||
|
||||
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 = ``;
|
||||
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();
|
||||
});
|
||||
});
|
||||
361
__tests__/import-runner.test.ts
Normal file
361
__tests__/import-runner.test.ts
Normal 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
157
__tests__/ui-shell.test.ts
Normal 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
352
add-feed-modal.ts
Normal 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
57
folder-suggest.ts
Normal 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
377
image-downloader.ts
Normal 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 `` 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: . 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 ``;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
427
import-modal.ts
Normal 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
268
import-runner.ts
Normal 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
270
settings-tab.ts
Normal 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();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
352
styles.css
352
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);
|
||||
}
|
||||
|
|
|
|||
35
ui-helpers.ts
Normal file
35
ui-helpers.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue