Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision e9ec5e4db7a301dfef1e2b514c04b6f0c6d35459)
+++ src/services/ActivityPubService.js	(revision 822d16d105ce410e1b5060af498cff83cf625e25)
@@ -280,4 +280,24 @@
  * Funkwhale hoeft te weten.
  */
+/**
+ * De waarschuwingstekst van een object, of niets.
+ *
+ * `summary` IS in AS2 een SAMENVATTING -- "a natural language summarization of
+ * the object". Dat Mastodon dat veld hergebruikt als waarschuwing is Mastodons
+ * conventie, en die zet er `sensitive` bij. Zonder `sensitive` is een summary
+ * dus gewoon een samenvatting.
+ *
+ * WordPress + ActivityPub stuurt daar de EXCERPT van een artikel in, netjes
+ * afgekapt voor Mastodon. Wij lazen dat als waarschuwing en verborgen de post
+ * daarmee achter zijn eigen eerste alinea (Barts melding, 13-8:
+ * europeanpirates.eu). Niemand krijgt dan te zien wat er staat, en de
+ * waarschuwing waarschuwt nergens voor.
+ */
+export function contentWarning(o) {
+  if (!o || !o.sensitive) return null;
+  const s = typeof o.summary === 'string' ? o.summary.trim() : '';
+  return s || null;
+}
+
 export function timelineFields(o) {
   // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
@@ -308,5 +328,22 @@
   }
 
-  // Note / Article / Question -- ongewijzigd gedrag.
+  // Een ARTIKEL heeft een titel, en die is het eerste wat je wilt zien. Zonder
+  // dit kwam een WordPress-post binnen als kale body: de titel zit in `name` en
+  // die gooiden we weg, terwijl de excerpt in `summary` ten onrechte als
+  // waarschuwing dienstdeed. Nu allebei goed -- en dit is dezelfde greep die
+  // resolveRemoteNote al doet voor niet-Note-objecten, dus de tijdlijn en het
+  // antwoordpad zeggen eindelijk hetzelfde.
+  if (o.type && o.type !== 'Note' && typeof o.name === 'string' && o.name.trim()) {
+    const kop = `<p><strong>${HtmlSanitizerService.escape ? HtmlSanitizerService.escape(o.name) : o.name}</strong></p>`;
+    const atts = mediaFromNote(o);
+    const pagina = pickLink(o.url, (mt) => !mt || /html/i.test(mt));
+    return {
+      html: HtmlSanitizerService.sanitize(kop + (o.content || '')),
+      atts,
+      url: pagina ? pagina.href : null,
+    };
+  }
+
+  // Note / Question -- ongewijzigd gedrag.
   const atts = (Array.isArray(o.attachment) ? o.attachment : [])
     .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
@@ -2463,5 +2500,5 @@
         // the timeline).
         for (const s of subs) {
-          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
+          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
           // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
           if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
@@ -2615,5 +2652,5 @@
         // post would keep linking to the old, now-dead URL.
         const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
-          .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
+          .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
         if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
         // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
@@ -2679,5 +2716,5 @@
             // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
             let inserted = false;
-            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, bn.summary || null); inserted = r.changes > 0; } catch { /* ignore */ }
+            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
             if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
             storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
@@ -3842,5 +3879,5 @@
     published: typeof o.published === 'string' ? o.published : undefined,
     sensitive: !!o.sensitive,
-    summary: typeof o.summary === 'string' ? o.summary.slice(0, 500) : undefined,
+    summary: (contentWarning(o) || '').slice(0, 500) || undefined,
     attachment: (() => {
       const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
@@ -3958,5 +3995,5 @@
     content: HtmlSanitizerService.sanitize(rawHtml),       // full, sanitized
     sensitive: !!note.sensitive,                            // remote CW → blur in the Cirkel
-    cw: note.summary || '',
+    cw: contentWarning(note) || '',
     images,
     // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
@@ -4988,5 +5025,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
+const SELFHEAL_VERSION = 22; // v22: summary is pas een waarschuwing MET sensitive, en een artikel houdt zijn titel
 async function fetchNoteAP(url) {
   try {
@@ -5375,5 +5412,5 @@
       const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
       try {
-        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, o.summary || null);
+        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, contentWarning(o));
         if (r && r.changes > 0) added++;
         // FEP-9098: keep custom-emoji tags from backfilled posts too.
@@ -5534,8 +5571,14 @@
         if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
         if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
-        const html = HtmlSanitizerService.sanitize(note.content || '');
-        const media = mediaFromNote(note);
+        // Door DEZELFDE bouwer als de innamekant (v22). Hij bouwde de inhoud
+        // hier zelf op, en daardoor miste een gerepareerde rij precies wat de
+        // inname wel doet -- de titel van een artikel bijvoorbeeld. Een
+        // zelfherstel dat een andere vorm oplevert dan de inname repareert naar
+        // een derde toestand.
+        const velden = timelineFields(note);
+        const html = velden.html;
+        const media = velden.atts.length ? JSON.stringify(velden.atts) : mediaFromNote(note);
         const nsfw = note.sensitive ? 1 : 0;   // re-sync NSFW/sensitive + CW onto already-cached posts
-        const cw = note.summary || null;
+        const cw = contentWarning(note);
         const url = note.url || null;          // re-sync the human url (catches a remote slug rename)
         const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
@@ -6590,5 +6633,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
