Changeset cc3cf7d in Klonkt


Ignore:
Timestamp:
07/26/2026 08:08:15 AM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
6fd0e20
Parents:
eb36688
Message:

FEP-044f: object-level quotes (quoteUrl / _misskey_quote) ook als quote-link serveren

De echte reden dat quotes niet toonden: bijna geen enkele instance zet een quote
als FEP-e232 Link-tag. FEP-044f zegt dat een quote meestal een object-property is
(quote / quoteUrl / quoteUri / _misskey_quote). We keken alleen naar de tag, dus
we vonden 0 quotes (self-heal v9: 0/40 overal).

Nu normaliseren we elke quote-vorm naar één FEP-e232-vormige Link (rel
_misskey_quote) en bewaren die in link_json, naast echte FEP-e232 Link-tags
(gededupliceerd op href). De client-parser markeert rel met "quote" al als
isQuote, dus de chip verschijnt zonder client-wijziging.

Self-heal v9 -> v10 zodat bestaande timeline-rijen opnieuw langs de detectie gaan
(v9 had zich al klaar-gemarkeerd).

Changed files:
src/services/ActivityPubService.js

  • extractQuoteUrl(note): leest quote/quoteUrl/quoteUri/_misskey_quote (string of embedded object)
  • extractLinkJson(note): FEP-e232 Link-tags + genormaliseerde object-quote, deduped
  • inbound Create, outbox-backfill en self-heal gebruiken nu extractLinkJson(note)
  • SELFHEAL_VERSION 9 -> 10

test/object-links.test.js

  • 3 tests: extractQuoteUrl (4 vormen + embedded), normalisatie naar Link, dedup met echte tag

remarks: 178 tests groen (was 175).

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    reb36688 rcc3cf7d  
    15391539          // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
    15401540          { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
    1541           // FEP-e232: keep the note's object-link (quote/ref) tags for the same read.
    1542           { const lj = extractObjectLinkTags(o.tag); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
     1541          // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
     1542          { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
    15431543          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
    15441544        }
     
    26052605}
    26062606
     2607// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
     2608// object-level property. FEP-044f §"how to recognise" lists them all:
     2609// `quote` (the FEP property, a string or an embedded Link/object), and the
     2610// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
     2611// This returns the quoted object's URL from whichever is present.
     2612export function extractQuoteUrl(note) {
     2613  if (!note || typeof note !== 'object') return null;
     2614  const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
     2615  if (!q) return null;
     2616  if (typeof q === 'string') return q || null;
     2617  if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
     2618  return null;
     2619}
     2620
     2621// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
     2622// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
     2623// _misskey_quote) so the client's single object-link path renders them all.
     2624// Deduped by href. Returns the JSON to store (or null if the note has neither).
     2625export function extractLinkJson(note) {
     2626  const links = [];
     2627  const fromTag = extractObjectLinkTags(note && note.tag);
     2628  if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
     2629  const qUrl = extractQuoteUrl(note);
     2630  if (qUrl && !links.some((l) => l && l.href === qUrl)) {
     2631    links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
     2632      rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
     2633  }
     2634  return links.length ? JSON.stringify(links) : null;
     2635}
     2636
    26072637// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
    26082638let _abCount, _cirkelPosts, _cirkelMembers;
     
    27012731// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
    27022732// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
    2703 const SELFHEAL_VERSION = 9; // v9: also re-capture FEP-e232 object-link tags (link_json) onto already-cached posts
     2733const SELFHEAL_VERSION = 10; // v10: also capture FEP-044f object-level quotes (quote/quoteUrl/quoteUri/_misskey_quote) into link_json
    27042734async function fetchNoteAP(url) {
    27052735  try {
     
    27622792        // FEP-9098: keep custom-emoji tags from backfilled posts too.
    27632793        { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, slug); } catch { /* ignore */ } } }
    2764         // FEP-e232: keep object-link (quote/ref) tags from backfilled posts too.
    2765         { const lj = extractObjectLinkTags(o.tag); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
     2794        // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
     2795        { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
    27662796        // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
    27672797        if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = COALESCE(poll_json, ?) WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, slug); } catch { /* ignore */ } }
     
    28972927        const url = note.url || null;          // re-sync the human url (catches a remote slug rename)
    28982928        const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
    2899         const link = extractObjectLinkTags(note.tag);   // FEP-e232: re-capture object-link tags (v9)
     2929        const link = extractLinkJson(note);   // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
    29002930        if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '')) {
    29012931          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, r.id);
  • test/object-links.test.js

    reb36688 rcc3cf7d  
    66const dbMod = await import('../src/config/database.js');
    77dbMod.initializeDatabase();
    8 const { extractObjectLinkTags, timelineObjectLinks } = await import('../src/services/ActivityPubService.js');
    9 const AP = { extractObjectLinkTags, timelineObjectLinks };
     8const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson } = await import('../src/services/ActivityPubService.js');
     9const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson };
    1010
    1111test('extractObjectLinkTags keeps AS2-profiled ld+json and activity+json Links; drops plain links and mentions', () => {
     
    3131  assert.equal(AP.timelineObjectLinks('not json'), undefined);
    3232});
     33
     34// FEP-044f: object-level quote properties are the common representation.
     35test('extractQuoteUrl reads quote / quoteUrl / quoteUri / _misskey_quote (string or embedded object)', () => {
     36  assert.equal(AP.extractQuoteUrl({ quote: 'https://s/objects/9' }), 'https://s/objects/9');
     37  assert.equal(AP.extractQuoteUrl({ quoteUrl: 'https://s/q1' }), 'https://s/q1');
     38  assert.equal(AP.extractQuoteUrl({ quoteUri: 'https://s/q2' }), 'https://s/q2');
     39  assert.equal(AP.extractQuoteUrl({ _misskey_quote: 'https://s/q3' }), 'https://s/q3');
     40  assert.equal(AP.extractQuoteUrl({ quote: { type: 'Link', href: 'https://s/q4' } }), 'https://s/q4');
     41  assert.equal(AP.extractQuoteUrl({ content: 'no quote' }), null);
     42});
     43
     44test('extractLinkJson normalises an object-level quote into one FEP-e232 Link (rel _misskey_quote)', () => {
     45  const json = AP.extractLinkJson({ content: 'nice', quoteUrl: 'https://s/objects/9' });
     46  const arr = AP.timelineObjectLinks(json);
     47  assert.equal(arr.length, 1);
     48  assert.equal(arr[0].href, 'https://s/objects/9');
     49  assert.ok(arr[0].rel.some((r) => r.includes('quote')));
     50});
     51
     52test('extractLinkJson merges a real FEP-e232 Link with an object-level quote, deduped by href', () => {
     53  const note = {
     54    tag: [{ type: 'Link', mediaType: 'application/activity+json', href: 'https://s/objects/9' }],
     55    quoteUrl: 'https://s/objects/9',   // same target → not duplicated
     56  };
     57  const arr = AP.timelineObjectLinks(AP.extractLinkJson(note));
     58  assert.equal(arr.length, 1);
     59  assert.equal(arr[0].href, 'https://s/objects/9');
     60});
Note: See TracChangeset for help on using the changeset viewer.