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

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

En dan vindt de uitgave ze ook: hashtags in RUWE inhoud (shaer-38y)

De Note kreeg de geschreven vorm terug, maar de uitgave-collectie nog niet. Die
leest rechtstreeks uit posts.content, en daar staan hashtags als kale tekst --
de linkjes worden pas door de renderer gemaakt. hashtagTags zoekt naar
<a class="hashtag">, vond dus niets, en viel stil terug op het tagveld: precies
de genormaliseerde vorm die niet moest winnen.

buildHashtagList kent nu een ruw-stand die ook kale #tags oppikt. Zo'n tag
moet met een LETTER beginnen -- "issue #12" is een nummer en geen hashtag, en
die zou anders als tag de deur uit gaan.

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

  • Property mode set to 100644
File size: 8.8 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}
Note: See TracBrowser for help on using the repository browser.