Docs per the docs-files-api skill: a 1–2 line @file; what a declaration is or does in JSDoc on it, not in // comments or the file header. Generated-by: Claude Opus 5.5
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
/** @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 `<![CDATA[${text.replace(XML_FORBIDDEN, "").replaceAll("]]>", "]]]]><![CDATA[>")}]]>`;
|
|
}
|
|
|
|
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
const list = (v: unknown): Record<string, unknown>[] => (Array.isArray(v) ? (v as Record<string, unknown>[]) : []);
|
|
|
|
/**
|
|
* 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:<text>\n` followed by its sticky notes, one
|
|
* `Sticky notes:<text>\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 `<item>`: title, link, packed description. The title is whole
|
|
* (diigo's export cuts titles at 255 characters). */
|
|
export function rssItem(item: Item): string {
|
|
return `<item><title>${cdata(str(item.title))}</title><link>${cdata(str(item.url))}</link><description>${cdata(itemDescription(item))}</description></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 `<?xml version="1.0"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<title>${cdata(channel.title)}</title>
|
|
<link></link>
|
|
<pubDate>${channel.pubDate}</pubDate>
|
|
<lastBuildDate>${channel.pubDate}</lastBuildDate>
|
|
${items.map(rssItem).join("\n")}
|
|
</channel>
|
|
</rss>
|
|
`;
|
|
}
|