diigo-cache: RSS in diigo's export shape, from cached items

src/rss.ts renders items as RSS 2.0 in the shape of diigo's RSS export:
title, link, and a description packing Tags:…;Description:…;
Annotations and Sticky notes:… (Highlight:/Sticky notes: lines). Unlike
the export it is well-formed (characters XML forbids are dropped; the
export carries some raw) and keeps whole titles (the export cuts at
255). Pure: dates come from the caller.

scripts/compare-export.ts (a program script: node, node:sqlite) loads a
cache's items, writes the RSS, and compares it with an export, keyed on
link, reporting counts by category.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 05:22:12 -05:00
1 parent 6af05190a6
commit e6deb9fda0
3 files changed
+204

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
/**
* @file cached diigo items as RSS 2.0, in the shape of diigo's RSS export
* (`Tags:…;Description:…;Annotations and Sticky notes:…`), but well-formed
* and with titles uncut. Pure: the caller supplies the dates.
*/
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)
*/
export function itemDescription(item: Item): string {
const tags = str(item.tags) || "no_tag";
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:${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>
`;
}