source: Klonkt/src/services/ap-core.js@ bac4bf3

main
Last change on this file since bac4bf3 was bac4bf3, checked in by Robin <roboburr@…>, 4 weeks ago

Paginering die echt pagineert (shaer-sk4)

Robin zag dat elke ?page= dezelfde inhoud gaf. Dat klopte, en het was geen halve
implementatie maar een omhulsel: pagedCollection veranderde alleen de VORM en
sneed nooit. first en last wezen allebei naar ?page=1, en de routes lazen
!!req.query.page -- of de parameter er STAAT, niet welke. Live gaf ?page=2 en
?page=99 dezelfde acht items, en noemden zichzelf pagina 1.

Nu: echt snijden, met next en prev, en een pagina die zijn eigen nummer
draagt. Een pagina voorbij het einde is LEEG en zegt dat ook -- hem naar de
laatste terugbuigen zou opnieuw een antwoord zijn dat over zichzelf liegt.

DE WORTEL HOUDT ZIJN ITEMS INLINE, en dat is geen slordigheid maar de reden dat
dit veilig is. Shaer leest een document en volgt next niet (KlonktClient.swift
orderedItems). Werd de wortel nu leeg, dan kreeg elke draaiende app nul items en
geen foutmelding -- dezelfde stille val waar ik op 10 augustus bij Funkwhale zelf
in trapte. Eerst de clients leren pagineren, dan pas de wortel afslanken.

WAT WEL EN NIET GEPAGINEERD IS. Volgers en following pagineren nu volledig: die
lijsten zitten al in het geheugen, dus dat kost niets extra. De outbox krijgt de
juiste VORM maar niet meer diepte: de route kapt al op twintig rijen in SQL.
Echt doorbladeren vraagt daar een LIMIT/OFFSET, en dat is lastiger dan bij
volgers omdat posts en tracks op datum door elkaar gevlochten worden en uit twee
tabellen komen -- een UNION met een offset erover, geen tweede slice. Dat staat
als naad in de code en op de bead, niet weggemoffeld.

Zeven tests, waaronder de klacht zelf: pagina 2 geeft ANDERE items dan pagina 1.

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

  • Property mode set to 100644
File size: 12.0 KB
Line 
1/**
2 * De primitieven die iedereen die ActivityPub uitzendt nodig heeft.
3 *
4 * Waarom dit bestand er is (shaer-drc): ActivityPubService.js was 6436 regels
5 * met 166 exports en tweeentwintig secties. Een submap zoals music/ kan pas
6 * zelfstandig bestaan als deze zes dingen ergens staan waar BEIDE uit kunnen
7 * putten -- anders importeert de submap uit ActivityPubService en importeert
8 * die weer terug, en dat is een kring.
9 *
10 * Guardianship laat zien hoe het wel moet: die map importeert alleen db en zijn
11 * eigen buren, nooit terug. Dit bestand maakt datzelfde mogelijk voor de rest.
12 *
13 * Alles hier is PUUR: geen database, geen netwerk, geen toestand. Dat is de
14 * grens -- komt daar iets bij dat wel iets weet, dan hoort het hier niet.
15 *
16 * EEN UITZONDERING OP "PUUR": AP_CONTEXT stelt zichzelf samen uit
17 * Guardianship.SHAER_CONTEXT. Die context is nu eenmaal de optelsom van ieders
18 * termen, dus dat hoort zo. Het maakt geen kring: guardianship kent alleen db
19 * en zijn eigen buren en importeert nooit terug.
20 */
21
22import * as Guardianship from './guardianship/index.js';
23
24export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
25// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
26// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
27// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
28// This is the same context shape Mastodon publishes, so Mastodon sees no change.
29export const AP_CONTEXT = [
30 'https://www.w3.org/ns/activitystreams',
31 'https://w3id.org/security/v1',
32 {
33 toot: 'http://joinmastodon.org/ns#',
34 schema: 'http://schema.org#',
35 sensitive: 'as:sensitive',
36 Hashtag: 'as:Hashtag',
37 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
38 discoverable: 'toot:discoverable',
39 // FEP-7628 (account moves): same term declaration Mastodon ships.
40 alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
41 movedTo: { '@id': 'as:movedTo', '@type': '@id' },
42 featured: { '@id': 'toot:featured', '@type': '@id' },
43 PropertyValue: 'schema:PropertyValue',
44 value: 'schema:value',
45 embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
46 // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
47 // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
48 // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
49 // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
50 // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
51 license: { '@id': 'schema:license', '@type': '@id' },
52 position: 'schema:position',
53 bitrate: 'schema:bitrate',
54 size: 'schema:contentSize',
55 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
56 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
57 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
58 votersCount: 'toot:votersCount',
59 // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
60 // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
61 // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
62 // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
63 // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
64 // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
65 // antwoordt.
66 category: { '@id': 'https://funkwhale.audio/ns#category' },
67 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
68 // module (src/services/guardianship/).
69 ...Guardianship.SHAER_CONTEXT,
70 },
71];
72
73/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
74 * bruikbare URL vinden. */
75export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
76
77export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
78export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
79
80/**
81 * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
82 * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
83 * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
84 * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
85 * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
86 */
87export function guessMediaType(u) {
88 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
89 return ({
90 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
91 webp: 'image/webp', avif: 'image/avif',
92 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
93 })[(e || '').toLowerCase()] || 'image/jpeg';
94}
95
96/**
97 * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
98 * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
99 * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
100 * terugval voor wat er handmatig is ingevuld.
101 */
102export function normalizeTags(t) {
103 if (Array.isArray(t)) return t;
104 if (typeof t === 'string') {
105 const s = t.trim(); if (!s) return [];
106 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
107 return s.split(',').map((x) => x.trim()).filter(Boolean);
108 }
109 return [];
110}
111
112/**
113 * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
114 * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
115 * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
116 * blijven kleingeschreven ("livemusic").
117 */
118export function tagParts(raw) {
119 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
120 if (!words.length) return null;
121 const slug = words.join('').toLowerCase();
122 if (!slug) return null;
123 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
124 return { label, slug };
125}
126
127/**
128 * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
129 * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
130 * naam niet: #DoenweNiet blijft #DoenweNiet.
131 */
132export function hashtagTags(base, content) {
133 const tags = [], seen = new Set();
134 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
135 let m;
136 while ((m = re.exec(content || ''))) {
137 const k = m[1].toLowerCase();
138 if (seen.has(k)) continue; seen.add(k);
139 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
140 }
141 return tags;
142}
143
144/**
145 * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
146 *
147 * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
148 * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
149 * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
150 * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
151 * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
152 * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
153 *
154 * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
155 * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
156 * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
157 * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
158 * tagveld, precies de vorm die hier juist niet moest winnen.
159 */
160export function hashtagTagsRuw(base, content) {
161 const tags = [], seen = new Set();
162 // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
163 // tag, en die zou hier anders als hashtag de deur uit gaan.
164 const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
165 let m;
166 while ((m = re.exec(content || ''))) {
167 const k = m[2].toLowerCase();
168 if (seen.has(k)) continue; seen.add(k);
169 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
170 }
171 return tags;
172}
173
174export function buildHashtagList(base, tagsField, content, opts = {}) {
175 const out = [], seen = new Set();
176 const uitLijf = opts.ruw
177 ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
178 : hashtagTags(base, content);
179 for (const h of uitLijf) {
180 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
181 out.push(h);
182 }
183 for (const t of normalizeTags(tagsField)) {
184 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
185 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
186 }
187 return out;
188}
189
190/**
191 * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
192 *
193 * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
194 * "first: This field is required" en "last: This field is required" -- de eerste
195 * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
196 * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
197 * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
198 * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
199 * opnieuw vergeet.
200 *
201 * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
202 * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
203 * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
204 * naar dezelfde.
205 *
206 * @param {string} id de collectie-uri, zonder query
207 * @param {Array} items wat erin zit (mag leeg)
208 * @param {object} opts
209 * totalItems als de telling niet items.length is (followers geeft publiek
210 * alleen een AANTAL en houdt de lijst dicht)
211 * page true -> een OrderedCollectionPage met partOf in plaats van de wortel
212 * extra velden die op de wortel horen (attributedTo, shaer:*)
213 */
214/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
215export const PAGINA_GROOTTE = 20;
216
217/**
218 * Een collectie, met ECHTE paginering (shaer-sk4).
219 *
220 * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
221 * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
222 * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
223 * pagina's identiek bleven; dit is waarom.
224 *
225 * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
226 * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
227 * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
228 * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
229 *
230 * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
231 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
232 * antwoord zijn dat over zichzelf liegt.
233 */
234export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, extra = {} } = {}) {
235 const lijst = items || [];
236 const telling = totalItems === undefined ? lijst.length : totalItems;
237 const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
238 const paginas = Math.max(1, Math.ceil(lijst.length / grootte));
239 const url = (n) => `${id}?page=${n}`;
240
241 if (page) {
242 const n = Math.max(1, Math.floor(Number(page)) || 1);
243 const deel = lijst.slice((n - 1) * grootte, n * grootte);
244 return {
245 '@context': AP_CONTEXT,
246 id: url(n),
247 type: 'OrderedCollectionPage',
248 partOf: id,
249 totalItems: telling,
250 ...(n > 1 ? { prev: url(n - 1) } : {}),
251 ...(n < paginas ? { next: url(n + 1) } : {}),
252 orderedItems: deel,
253 };
254 }
255 return {
256 '@context': AP_CONTEXT,
257 id,
258 type: 'OrderedCollection',
259 ...extra,
260 totalItems: telling,
261 first: url(1),
262 last: url(paginas),
263 orderedItems: lijst,
264 };
265}
Note: See TracBrowser for help on using the repository browser.