From c398669ae65ce18601509c2e30bbabc5058efd60 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 15:07:06 +0800 Subject: [PATCH] feat(library): core types + rss/atom chapter feed parser --- packages/obsidian/src/library/rss.ts | 97 ++++++++++++++++++ packages/obsidian/src/library/types.ts | 28 +++++ tests/library-rss.test.ts | 135 +++++++++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 packages/obsidian/src/library/rss.ts create mode 100644 packages/obsidian/src/library/types.ts create mode 100644 tests/library-rss.test.ts diff --git a/packages/obsidian/src/library/rss.ts b/packages/obsidian/src/library/rss.ts new file mode 100644 index 0000000..5f5d681 --- /dev/null +++ b/packages/obsidian/src/library/rss.ts @@ -0,0 +1,97 @@ +export interface FeedItem { + title: string; + date: string; // ISO (YYYY-MM-DD) or '' + id: string; +} + +const BLOCK_RE = /<(item|entry)\b[^>]*>([\s\S]*?)<\/\1>/gi; + +function extractTag(block: string, tag: string): string | null { + const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'); + const m = re.exec(block); + return m ? m[1] : null; +} + +function unwrapCdata(raw: string): string { + const m = /^\s*\s*$/.exec(raw); + return m ? m[1] : raw; +} + +function decodeEntities(raw: string): string { + return raw + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +function cleanText(raw: string | null): string { + if (raw === null) return ''; + return decodeEntities(unwrapCdata(raw)).trim(); +} + +function toIsoDate(raw: string | null): string { + if (!raw) return ''; + const d = new Date(cleanText(raw)); + if (isNaN(d.getTime())) return ''; + return d.toISOString().slice(0, 10); +} + +/** + * RSS 2.0 + Atom , regex-based (no DOM libs). Entities decoded + * (& < > " '), CDATA unwrapped. Invalid/missing dates → ''. + */ +export function parseFeed(xml: string): FeedItem[] { + const items: FeedItem[] = []; + if (!xml) return items; + + let match: RegExpExecArray | null; + BLOCK_RE.lastIndex = 0; + while ((match = BLOCK_RE.exec(xml)) !== null) { + const block = match[2]; + const title = cleanText(extractTag(block, 'title')); + const rawDate = extractTag(block, 'pubDate') ?? extractTag(block, 'updated'); + const id = cleanText(extractTag(block, 'guid') ?? extractTag(block, 'id')); + items.push({ title, date: toIsoDate(rawDate), id }); + } + + return items; +} + +const CHAPTER_RE = /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i; + +/** /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i → parseFloat; null when no match */ +export function extractChapterNumber(title: string): number | null { + const m = CHAPTER_RE.exec(title); + if (!m) return null; + return parseFloat(m[2]); +} + +/** + * Items assumed newest-first. Picks the item with the highest parseable + * chapter number; falls back to date ordering (max ISO date) when no item + * title yields a parseable chapter number. + */ +export function latestChapter(items: FeedItem[]): { chapter: number | null; date: string; title: string } | null { + if (items.length === 0) return null; + + let best: FeedItem | null = null; + let bestChapter: number | null = null; + + for (const item of items) { + const chapter = extractChapterNumber(item.title); + if (chapter !== null && (bestChapter === null || chapter > bestChapter)) { + bestChapter = chapter; + best = item; + } + } + + if (best) return { chapter: bestChapter, date: best.date, title: best.title }; + + let latest = items[0]; + for (const item of items) { + if (item.date && (!latest.date || item.date > latest.date)) latest = item; + } + return { chapter: null, date: latest.date, title: latest.title }; +} diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts new file mode 100644 index 0000000..e326e2b --- /dev/null +++ b/packages/obsidian/src/library/types.ts @@ -0,0 +1,28 @@ +export type HttpJsonFn = (url: string, headers: Record) => Promise; +export type HttpTextFn = (url: string, headers: Record) => Promise; + +export interface LibraryNoteCtx { + frontmatter: Record; + body: string; + filename: string; +} + +export interface MediaTypeSpec { + typeName: 'manga' | 'book' | 'game' | 'comic'; + itemType: string; // 'manga_item' etc. + folderSettingKey: string; + enabledSettingKey: string; + throttleMs: number; + hasId(fm: Record): boolean; + isActive(fm: Record): boolean; + resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null>; // returns fm patches (id fields) + sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip +} + +export interface SpecDeps { + http: HttpJsonFn; + httpText: HttpTextFn; + getKey(name: 'rawg' | 'comicvine'): string; // '' when unset + log(msg: string): void; + notify(msg: string): void; +} diff --git a/tests/library-rss.test.ts b/tests/library-rss.test.ts new file mode 100644 index 0000000..c9f555f --- /dev/null +++ b/tests/library-rss.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test'; +import { extractChapterNumber, latestChapter, parseFeed } from 'packages/obsidian/src/library/rss'; + +const rssSample = ` + + +Chainsaw Man Updates + +Chainsaw Man Chapter 214 +Thu, 30 Jul 2026 12:00:00 GMT +https://example.com/csm-214 + + +Chainsaw Man Chapter 213.5 +Thu, 23 Jul 2026 12:00:00 GMT +https://example.com/csm-213-5 + + +Chainsaw Man Chapter 213 +Thu, 16 Jul 2026 12:00:00 GMT +https://example.com/csm-213 + + +`; + +const atomSample = ` + +Some Manga Feed + +Ch. 12 — name +2026-08-01T09:30:00Z +https://example.com/entry/12 + +`; + +const cdataSample = ` +<![CDATA[One Piece Chapter 1120]]> +Mon, 01 Jun 2026 00:00:00 GMT +id-cdata +`; + +const entitySample = ` +Attack & Titan 'Special' Chapter 5 +Mon, 01 Jun 2026 00:00:00 GMT +id-entity +`; + +const numberlessSample = ` + +Chainsaw Man - Extra Announcement +Thu, 30 Jul 2026 12:00:00 GMT +a + + +Chainsaw Man - Fan Art Contest +Thu, 16 Jul 2026 12:00:00 GMT +b + +`; + +describe('parseFeed', () => { + test('RSS 2.0 — 3 items, title/date/id mapped, newest first as-authored', () => { + const items = parseFeed(rssSample); + expect(items).toEqual([ + { title: 'Chainsaw Man Chapter 214', date: '2026-07-30', id: 'https://example.com/csm-214' }, + { title: 'Chainsaw Man Chapter 213.5', date: '2026-07-23', id: 'https://example.com/csm-213-5' }, + { title: 'Chainsaw Man Chapter 213', date: '2026-07-16', id: 'https://example.com/csm-213' }, + ]); + }); + + test('Atom — entry title/updated/id mapped', () => { + const items = parseFeed(atomSample); + expect(items).toEqual([{ title: 'Ch. 12 — name', date: '2026-08-01', id: 'https://example.com/entry/12' }]); + }); + + test('CDATA title unwrapped', () => { + const items = parseFeed(cdataSample); + expect(items[0].title).toBe('One Piece Chapter 1120'); + }); + + test('entity-encoded title decoded', () => { + const items = parseFeed(entitySample); + expect(items[0].title).toBe(`Attack & Titan 'Special' Chapter 5`); + }); + + test('empty xml → []', () => { + expect(parseFeed('')).toEqual([]); + }); + + test('garbage xml → []', () => { + expect(parseFeed('not xml at all, just some random text')).toEqual([]); + }); +}); + +describe('extractChapterNumber', () => { + test('"Chapter 214" → 214', () => { + expect(extractChapterNumber('Chapter 214')).toBe(214); + }); + test('"ch.213.5" → 213.5', () => { + expect(extractChapterNumber('ch.213.5')).toBe(213.5); + }); + test('"#77" → 77', () => { + expect(extractChapterNumber('#77')).toBe(77); + }); + test('"Episode 5" → null', () => { + expect(extractChapterNumber('Episode 5')).toBeNull(); + }); + test('"Vol. 3 Chapter 21" → 21', () => { + expect(extractChapterNumber('Vol. 3 Chapter 21')).toBe(21); + }); + test('numberless title → null', () => { + expect(extractChapterNumber('Chainsaw Man - Extra Announcement')).toBeNull(); + }); +}); + +describe('latestChapter', () => { + test('empty items → null', () => { + expect(latestChapter([])).toBeNull(); + }); + + test('RSS sample → highest chapter (214) wins', () => { + const result = latestChapter(parseFeed(rssSample)); + expect(result).toEqual({ chapter: 214, date: '2026-07-30', title: 'Chainsaw Man Chapter 214' }); + }); + + test('Atom sample → single entry chapter 12', () => { + const result = latestChapter(parseFeed(atomSample)); + expect(result).toEqual({ chapter: 12, date: '2026-08-01', title: 'Ch. 12 — name' }); + }); + + test('numberless titles → date-based fallback, chapter null', () => { + const result = latestChapter(parseFeed(numberlessSample)); + expect(result).toEqual({ chapter: null, date: '2026-07-30', title: 'Chainsaw Man - Extra Announcement' }); + }); +});