Files
svemagie 7e9e5c7b31 fix(tags): normalize nested category paths for ActivityPub hashtags
Nested categories like `on/tech` or `art/music` were being sent as-is
to ActivityPub consumers. Three fixes:

- status.js (Mastodon Client API): raw category used as tag name/URL
  instead of the normalized last segment
- jf2-to-as2.js buildPlainTags/buildFedifyTags: href used
  encodeURIComponent() on the full path, encoding the `/` separator
  and producing broken category URLs (e.g. `categories/on%2Ftech`
  instead of `categories/on/tech`)

Tag names are now consistently normalized to the last path segment
(`on/tech` → `#tech`). Category hrefs encode each segment individually
but preserve the `/` separator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 20:17:17 +02:00

328 lines
10 KiB
JavaScript

/**
* Status entity serializer for Mastodon Client API.
*
* Converts ap_timeline documents into the Mastodon Status JSON shape.
*
* CORRECTED field mappings (based on actual extractObjectData output):
* content <- content.html (NOT contentHtml)
* uri <- uid (NOT activityUrl)
* account <- author { name, url, photo, handle, emojis, bot }
* media <- photo[] + video[] + audio[] (NOT single attachments[])
* card <- linkPreviews[0] (NOT single card)
* tags <- category[] (NOT tags[])
* counts <- counts.boosts, counts.likes, counts.replies
* boost <- type:"boost" + boostedBy (flat, NOT nested sharedItem)
*/
import { serializeAccount } from "./account.js";
import { sanitizeHtml } from "./sanitize.js";
import { remoteActorId } from "../helpers/id-mapping.js";
// Module-level defaults set once at startup via setLocalIdentity()
let _localPublicationUrl = "";
let _localHandle = "";
/**
* Set the local identity for own-post detection.
* Called once during plugin init.
* @param {string} publicationUrl - e.g. "https://rmendes.net/"
* @param {string} handle - e.g. "rick"
*/
export function setLocalIdentity(publicationUrl, handle) {
_localPublicationUrl = publicationUrl;
_localHandle = handle;
}
/**
* Serialize an ap_timeline document as a Mastodon Status entity.
*
* @param {object} item - ap_timeline document
* @param {object} options
* @param {string} options.baseUrl - Server base URL
* @param {Set<string>} [options.favouritedIds] - UIDs the user has liked
* @param {Set<string>} [options.rebloggedIds] - UIDs the user has boosted
* @param {Set<string>} [options.bookmarkedIds] - UIDs the user has bookmarked
* @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
* @returns {object} Mastodon Status entity
*/
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) {
if (!item) return null;
// Use MongoDB ObjectId as the status ID — unique and chronologically sortable.
const id = item._id.toString();
const uid = item.uid || "";
const url = item.url || uid;
// Handle boosts — reconstruct nested reblog wrapper
if (item.type === "boost" && item.boostedBy) {
// The outer status represents the boost action
// The inner status is the original post (the item itself minus boost metadata)
const innerItem = { ...item, type: "note", boostedBy: undefined, boostedAt: undefined };
const innerStatus = serializeStatus(innerItem, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds,
});
return {
id,
created_at: item.boostedAt || item.createdAt || new Date().toISOString(),
in_reply_to_id: null,
in_reply_to_account_id: null,
sensitive: false,
spoiler_text: "",
visibility: item.visibility || "public",
language: null,
uri: uid,
url,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
edited_at: null,
favourited: false,
reblogged: rebloggedIds?.has(uid) || false,
muted: false,
bookmarked: false,
pinned: false,
content: "",
filtered: null,
reblog: innerStatus,
application: null,
account: serializeAccount(item.boostedBy, { baseUrl }),
media_attachments: [],
mentions: [],
tags: [],
emojis: [],
card: null,
poll: null,
};
}
// Regular status (note, article, question)
let content = item.content?.html || item.content?.text || "";
// Append permalink for own posts at read time — matches what fediverse
// users see via federation (jf2-to-as2 appends the same link).
// Done here instead of at write time so it survives backfills and cleanups.
const isOwnPost = _localPublicationUrl && item.author?.url === _localPublicationUrl;
const postUrl = item.uid || item.url;
if (isOwnPost && postUrl && !content.includes(postUrl)) {
const escaped = postUrl.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
content += `\n<p>\u{1F517} <a href="${escaped}">${escaped}</a></p>`;
}
const spoilerText = item.summary || "";
const sensitive = item.sensitive || false;
const visibility = item.visibility || "public";
const language = item.language || null;
const published = item.published || item.createdAt || new Date().toISOString();
const editedAt = item.updated || item.updatedAt || null;
// Media attachments — merge photo, video, audio arrays
const mediaAttachments = [];
let attachmentCounter = 0;
if (item.photo?.length > 0) {
for (const p of item.photo) {
mediaAttachments.push({
id: `${id}-${attachmentCounter++}`,
type: "image",
url: typeof p === "string" ? p : p.url,
preview_url: typeof p === "string" ? p : p.url,
remote_url: typeof p === "string" ? p : p.url,
text_url: null,
meta: buildImageMeta(p),
description: typeof p === "object" ? p.alt || "" : "",
blurhash: null,
});
}
}
if (item.video?.length > 0) {
for (const v of item.video) {
mediaAttachments.push({
id: `${id}-${attachmentCounter++}`,
type: "video",
url: typeof v === "string" ? v : v.url,
preview_url: typeof v === "string" ? v : v.url,
remote_url: typeof v === "string" ? v : v.url,
text_url: null,
meta: null,
description: typeof v === "object" ? v.alt || "" : "",
blurhash: null,
});
}
}
if (item.audio?.length > 0) {
for (const a of item.audio) {
mediaAttachments.push({
id: `${id}-${attachmentCounter++}`,
type: "audio",
url: typeof a === "string" ? a : a.url,
preview_url: typeof a === "string" ? a : a.url,
remote_url: typeof a === "string" ? a : a.url,
text_url: null,
meta: null,
description: typeof a === "object" ? a.alt || "" : "",
blurhash: null,
});
}
}
// Link preview -> card
const card = serializeCard(item.linkPreviews?.[0]);
// Tags from category[] — normalize nested paths (e.g. "on/tech" → "tech")
const tags = (item.category || []).map((tag) => {
const normalized = tag.split("/").at(-1).replace(/\s+/g, "");
return {
name: normalized,
url: `${baseUrl}/tags/${encodeURIComponent(normalized)}`,
};
});
// Mentions — use actorUrl for deterministic ID, parse acct from handle
const mentions = (item.mentions || []).map((m) => {
const handle = (m.name || "").replace(/^@/, "");
const parts = handle.split("@");
return {
id: m.actorUrl ? remoteActorId(m.actorUrl) : "0",
username: parts[0] || handle,
url: m.url || m.actorUrl || "",
acct: handle,
};
});
// Custom emojis
const emojis = (item.emojis || []).map((e) => ({
shortcode: e.shortcode || "",
url: e.url || "",
static_url: e.url || "",
visible_in_picker: true,
}));
// Counts
const repliesCount = item.counts?.replies ?? 0;
const reblogsCount = item.counts?.boosts ?? 0;
const favouritesCount = item.counts?.likes ?? 0;
// Poll
const poll = serializePoll(item, id);
// Interaction state
const favourited = favouritedIds?.has(uid) || false;
const reblogged = rebloggedIds?.has(uid) || false;
const bookmarked = bookmarkedIds?.has(uid) || false;
const pinned = pinnedIds?.has(uid) || false;
return {
id,
created_at: published,
in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
in_reply_to_account_id: replyAccountIdMap?.get(item.inReplyTo) ?? null,
sensitive,
spoiler_text: spoilerText,
visibility,
language,
uri: uid,
url,
replies_count: repliesCount,
reblogs_count: reblogsCount,
favourites_count: favouritesCount,
edited_at: editedAt || null,
favourited,
reblogged,
muted: false,
bookmarked,
pinned,
content: sanitizeHtml(content),
filtered: null,
reblog: null,
application: null,
account: item.author
? serializeAccount(item.author, {
baseUrl,
isLocal: !!(_localPublicationUrl && item.author.url === _localPublicationUrl),
handle: _localHandle,
})
: null,
media_attachments: mediaAttachments,
mentions,
tags,
emojis,
card,
poll,
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Serialize a linkPreview object as a Mastodon PreviewCard.
*/
export function serializeCard(preview) {
if (!preview) return null;
return {
url: preview.url || "",
title: preview.title || "",
description: preview.description || "",
type: "link",
author_name: "",
author_url: "",
provider_name: preview.domain || "",
provider_url: "",
html: "",
width: 0,
height: 0,
image: preview.image || null,
embed_url: "",
blurhash: null,
language: null,
published_at: null,
};
}
/**
* Build image meta object for media attachments.
*/
function buildImageMeta(photo) {
if (typeof photo === "string") return null;
if (!photo.width && !photo.height) return null;
return {
original: {
width: photo.width || 0,
height: photo.height || 0,
size: photo.width && photo.height ? `${photo.width}x${photo.height}` : null,
aspect: photo.width && photo.height ? photo.width / photo.height : null,
},
};
}
/**
* Serialize poll data from a timeline item.
*/
function serializePoll(item, statusId) {
if (!item.pollOptions?.length) return null;
const totalVotes = item.pollOptions.reduce((sum, o) => sum + (o.votes || 0), 0);
return {
id: statusId,
expires_at: item.pollEndTime || null,
expired: item.pollClosed || false,
multiple: false,
votes_count: totalVotes,
voters_count: item.votersCount || null,
options: item.pollOptions.map((o) => ({
title: o.name || "",
votes_count: o.votes || 0,
})),
emojis: [],
voted: false,
own_votes: [],
};
}