/** @file cached diigo items as RSS 2.0, in the shape of diigo's RSS export. */ import type { Item } from "./items.js"; /** characters XML 1.0 forbids (the C0 controls but tab, LF, CR); * diigo's export carries some raw, so strict parsers reject it */ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g; /** text as one or more CDATA sections: forbidden characters dropped, * a literal `]]>` split across two sections */ function cdata(text: string): string { return `", "]]]]>")}]]>`; } const str = (v: unknown): string => (typeof v === "string" ? v : ""); const list = (v: unknown): Record[] => (Array.isArray(v) ? (v as Record[]) : []); /** * the packed description diigo's export uses: * `Tags:a,b;Description:…;Annotations and Sticky notes:…` — tags * comma-separated (`no_tag` when none), then each highlight as * `Highlight:\n` followed by its sticky notes, one * `Sticky notes:\n` each (notes may span lines). A diigo *note* * item's body (`note_content`) comes first, as a `Highlight:` line. */ export function itemDescription(item: Item): string { const tags = str(item.tags) || "no_tag"; const body = str(item.note_content) ? `Highlight:${str(item.note_content)}\n` : ""; const notes = list(item.annotations) .map((a) => `Highlight:${str(a.content)}\n${list(a.stickyNotes).map((n) => `Sticky notes:${str(n.content)}\n`).join("")}`) .join(""); return `Tags:${tags};Description:${str(item.description)};Annotations and Sticky notes:${body}${notes}`; } /** one ``: title, link, packed description. The title is whole * (diigo's export cuts titles at 255 characters). */ export function rssItem(item: Item): string { return `${cdata(str(item.title))}${cdata(str(item.url))}${cdata(itemDescription(item))}`; } /** * a whole feed * * @param channel `pubDate` in RFC 822 form is the caller's to supply * (no clock here) */ export function toRss(items: Item[], channel: { title: string; pubDate: string }): string { return ` ${cdata(channel.title)} ${channel.pubDate} ${channel.pubDate} ${items.map(rssItem).join("\n")} `; }