Files
Ricardo 4819c229cd feat: restore full microsub implementation with reader UI
Restores complete implementation from feat/endpoint-microsub branch:
- Reader UI with views (reader.njk, channel.njk, feeds.njk, etc.)
- Feed polling, parsing, and normalization
- WebSub subscriber
- SSE realtime updates
- Redis caching
- Search indexing
- Media proxy
- Webmention processing
2026-02-06 20:20:25 +01:00

171 lines
4.3 KiB
JavaScript

/**
* jf2 utility functions for Microsub
* @module utils/jf2
*/
import { createHash } from "node:crypto";
/**
* Generate a unique ID for an item based on feed URL and item identifier
* @param {string} feedUrl - Feed URL
* @param {string} itemId - Item ID or URL
* @returns {string} Unique item ID
*/
export function generateItemUid(feedUrl, itemId) {
const input = `${feedUrl}:${itemId}`;
return createHash("sha256").update(input).digest("hex").slice(0, 24);
}
/**
* Generate a random channel UID
* @returns {string} 24-character random string
*/
export function generateChannelUid() {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let index = 0; index < 24; index++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Create a jf2 Item from normalized feed data
* @param {object} data - Normalized item data
* @param {object} source - Feed source metadata
* @returns {object} jf2 Item object
*/
export function createJf2Item(data, source) {
return {
type: "entry",
uid: data.uid,
url: data.url,
name: data.name || undefined,
content: data.content || undefined,
summary: data.summary || undefined,
published: data.published,
updated: data.updated || undefined,
author: data.author || undefined,
category: data.category || [],
photo: data.photo || [],
video: data.video || [],
audio: data.audio || [],
// Interaction types
"like-of": data.likeOf || [],
"repost-of": data.repostOf || [],
"bookmark-of": data.bookmarkOf || [],
"in-reply-to": data.inReplyTo || [],
// Internal properties (prefixed with _)
_id: data._id,
_is_read: data._is_read || false,
_source: source,
};
}
/**
* Create a jf2 Card (author/person)
* @param {object} data - Author data
* @returns {object} jf2 Card object
*/
export function createJf2Card(data) {
if (!data) return;
return {
type: "card",
name: data.name || undefined,
url: data.url || undefined,
photo: data.photo || undefined,
};
}
/**
* Create a jf2 Content object
* @param {string} text - Plain text content
* @param {string} html - HTML content
* @returns {object|undefined} jf2 Content object
*/
export function createJf2Content(text, html) {
if (!text && !html) return;
return {
text: text || stripHtml(html),
html: html || undefined,
};
}
/**
* Strip HTML tags from string
* @param {string} html - HTML string
* @returns {string} Plain text
*/
export function stripHtml(html) {
if (!html) return "";
return html.replaceAll(/<[^>]*>/g, "").trim();
}
/**
* Create a jf2 Feed response
* @param {object} options - Feed options
* @param {Array} options.items - Array of jf2 items
* @param {object} options.paging - Pagination cursors
* @returns {object} jf2 Feed object
*/
export function createJf2Feed({ items, paging }) {
const feed = {
items: items || [],
};
if (paging) {
feed.paging = {};
if (paging.before) feed.paging.before = paging.before;
if (paging.after) feed.paging.after = paging.after;
}
return feed;
}
/**
* Create a Channel response object
* @param {object} channel - Channel data
* @param {number} unreadCount - Number of unread items
* @returns {object} Channel object for API response
*/
export function createChannelResponse(channel, unreadCount = 0) {
return {
uid: channel.uid,
name: channel.name,
unread: unreadCount > 0 ? unreadCount : false,
};
}
/**
* Create a Feed response object
* @param {object} feed - Feed data
* @returns {object} Feed object for API response
*/
export function createFeedResponse(feed) {
return {
type: "feed",
url: feed.url,
name: feed.title || undefined,
photo: feed.photo || undefined,
};
}
/**
* Detect interaction type from item properties
* @param {object} item - jf2 item
* @returns {string|undefined} Interaction type
*/
export function detectInteractionType(item) {
if (item["like-of"]?.length > 0 || item.likeOf?.length > 0) return "like";
if (item["repost-of"]?.length > 0 || item.repostOf?.length > 0)
return "repost";
if (item["bookmark-of"]?.length > 0 || item.bookmarkOf?.length > 0)
return "bookmark";
if (item["in-reply-to"]?.length > 0 || item.inReplyTo?.length > 0)
return "reply";
if (item.checkin) return "checkin";
return;
}