Author SHA1 Message Date
cedric 926fecd125 feat: add ayisyen
Déploiement API BETA / build (push) Successful in 2m23s
Déploiement API PROD / build (push) Successful in 2m26s
Déploiement API BETA / deploy (push) Successful in 1m16s
Déploiement API PROD / deploy (push) Successful in 1m48s
2026-08-11 07:25:55 +04:00
cedric ba5792517b Merge pull request 'Ajouter les premières langues africaines (traduction)' (#6) from feat/africa into master
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 45s
Déploiement API PROD / deploy (push) Successful in 52s
Reviewed-on: #6
2026-07-05 22:08:54 +00:00
cedric b01132f104 feat: ajouter 14 langues africaines (dont le yoruba, hors DeepL)
Déploiement API BETA / build (push) Successful in 2m26s
Déploiement API BETA / deploy (push) Successful in 47s
Vérification PR / build (pull_request) Successful in 2m21s
Vérification PR / deploy-beta (pull_request) Successful in 43s
2026-07-06 00:05:33 +04:00
cedric 821b9b96aa feat: mettre en avant les langues asiatiques dans l'ordre des traductions
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 48s
Déploiement API PROD / deploy (push) Successful in 55s
2026-07-05 22:28:27 +04:00
cedric 842e87543c revert: garder les mentions dans les commentaires importés
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / deploy (push) Successful in 54s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 44s
2026-07-05 20:10:51 +04:00
cedric ecbcf5d86e fix: retirer la mention initiale des commentaires importés
Déploiement API BETA / build (push) Successful in 2m16s
Déploiement API PROD / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-05 19:39:33 +04:00
cedric 128c027af1 fix: extraire l'id de la relation avant de la déréférencer
Déploiement API BETA / build (push) Successful in 2m27s
Déploiement API PROD / build (push) Successful in 2m21s
Déploiement API BETA / deploy (push) Successful in 47s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-05 19:22:29 +04:00
cedric 29bdf72640 fix: réparer la relation commentaire.parole / parole.commentaires
Déploiement API BETA / build (push) Successful in 2m25s
Déploiement API PROD / build (push) Successful in 2m22s
Déploiement API BETA / deploy (push) Successful in 46s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-05 19:04:39 +04:00
cedric fb60226bd4 fix: publier les commentaires bokante importés
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 47s
Déploiement API PROD / deploy (push) Successful in 52s
2026-07-05 18:39:01 +04:00
cedric 34f3a7783b docs: documenter les commentaires fédérés (bokante)
Déploiement API BETA / build (push) Successful in 2m20s
Déploiement API PROD / build (push) Successful in 2m21s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 56s
2026-07-05 02:06:23 +04:00
cedric 03960cc952 Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — backend' (#5) from feat/comment-fedi into master
Déploiement API BETA / build (push) Successful in 2m20s
Déploiement API PROD / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 53s
Reviewed-on: #5
2026-07-04 21:24:31 +00:00
12 changed files with 188 additions and 17 deletions
+31
View File
@@ -163,6 +163,37 @@ curl -H "Authorization: Bearer <token>" \
-o dataset.jsonl -o dataset.jsonl
``` ```
## Commentaires fédérés (Mastodon / bokante.o-k-i.net)
Chaque parole publiée peut obtenir un statut miroir sur l'instance Mastodon de
l'organisation, [bokante.o-k-i.net](https://bokante.o-k-i.net), et les réponses reçues
sur ce statut sont importées comme commentaires. Voir le RFC dédié pour le détail de la
conception (`RFC-commentaires-activitypub-2026-07-04.md`, à la racine du dossier
`PAWOL.NU`).
**Variables d'environnement :**
| Variable | Obligatoire | Description |
|---|---|---|
| `BOKANTE_ACCESS_TOKEN` | Oui, pour activer la fonctionnalité | Jeton d'application Mastodon (scopes `write:statuses`, `read:statuses`) d'un compte bot dédié sur bokante.o-k-i.net |
| `BOKANTE_INSTANCE_URL` | Non | Instance Mastodon cible (défaut : `https://bokante.o-k-i.net`) |
| `BOKANTE_BACKFILL_CRON` | Non | Règle cron du backfill du catalogue existant (défaut : `0 * * * *`, une parole/heure) |
Sans `BOKANTE_ACCESS_TOKEN`, toute la fonctionnalité (publication du miroir, import des
réponses, backfill) est un no-op silencieux — comme les intégrations Telegram/Revolt.
**Comment obtenir le jeton :** créer un compte bot dédié sur bokante.o-k-i.net, puis dans
*Préférences → Développement → Nouvelle application*, cocher uniquement `write:statuses`
et `read:statuses`. Le jeton d'accès est affiché directement sur la page de l'application
créée.
**Tâches planifiées (cron) :**
- Publication du miroir : déclenchée à chaque publication d'une parole (pas de cron).
- Import des réponses : toutes les 20 minutes.
- Backfill du catalogue existant : une parole par exécution (la plus ancienne sans
miroir en premier), au rythme défini par `BOKANTE_BACKFILL_CRON`. S'arrête de
lui-même une fois le catalogue rattrapé.
## License ## License
Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI) Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
@@ -27,11 +27,33 @@ describe('commentaire afterCreate — notification email', () => {
const {afterCreate} = await loadLifecycles(strapiMock); const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {user: 1, parole: 7, contenu: '<img src=x onerror=alert(1)>'}}}); await afterCreate({params: {data: {user: {connect: [{id: 1}]}, parole: {connect: [{id: 7}]}, contenu: '<img src=x onerror=alert(1)>'}}});
expect(emailSend).toHaveBeenCalledTimes(1); expect(emailSend).toHaveBeenCalledTimes(1);
const [payload] = emailSend.mock.calls[0]; const [payload] = emailSend.mock.calls[0];
expect(payload.text).toBe('<img src=x onerror=alert(1)>'); expect(payload.text).toBe('<img src=x onerror=alert(1)>');
expect(payload.html).toBeUndefined(); expect(payload.html).toBeUndefined();
}); });
it('extrait l\'id de la relation quelle que soit sa forme (connect, set, id brut)', async () => {
const paroleFindOne = vi.fn(async ({where}) => ({id: where.id, titre: 'Mon titre'}));
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return {findOne: vi.fn(async () => null)};
if (uid === 'api::parole.parole') return {findOne: paroleFindOne};
throw new Error(`unexpected uid: ${uid}`);
})
},
plugins: {email: {services: {email: {send: vi.fn()}}}}
};
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {parole: {set: [{id: 7}]}, contenu: 'ok'}}});
expect(paroleFindOne).toHaveBeenCalledWith({where: {id: 7}});
await afterCreate({params: {data: {parole: 7, contenu: 'ok'}}});
expect(paroleFindOne).toHaveBeenCalledWith({where: {id: 7}});
});
}); });
@@ -2,6 +2,17 @@
const { ApplicationError, NotFoundError } = require('@strapi/utils').errors; const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
// Le Document Service transforme les relations en { connect: [{id}] } ou
// { set: [{id}] } avant que les hooks bas niveau (beforeCreate/afterCreate)
// ne reçoivent `data` : un id brut n'est plus systématiquement garanti ici.
const idRelasyonAn = valè => {
if (valè == null || typeof valè !== 'object') {
return valè;
}
return valè.connect?.[0]?.id ?? valè.set?.[0]?.id ?? null;
};
const jwennUserEpiId = async userId => { const jwennUserEpiId = async userId => {
if (!userId) { if (!userId) {
return null; return null;
@@ -47,8 +58,8 @@ module.exports = {
}, },
afterCreate: async event => { afterCreate: async event => {
const {data} = event.params; const {data} = event.params;
const user = await jwennUserEpiId(data.user); const user = await jwennUserEpiId(idRelasyonAn(data.user));
const parole = await jwennParoleEpiId(data.parole); const parole = await jwennParoleEpiId(idRelasyonAn(data.parole));
if (!parole) { if (!parole) {
throw new NotFoundError('Texte introuvable.'); throw new NotFoundError('Texte introuvable.');
@@ -27,8 +27,9 @@
}, },
"parole": { "parole": {
"type": "relation", "type": "relation",
"relation": "oneToOne", "relation": "manyToOne",
"target": "api::parole.parole" "target": "api::parole.parole",
"inversedBy": "commentaires"
}, },
"origine": { "origine": {
"type": "enumeration", "type": "enumeration",
@@ -82,7 +82,8 @@
"commentaires": { "commentaires": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::commentaire.commentaire" "target": "api::commentaire.commentaire",
"mappedBy": "parole"
}, },
"prioriteArtistes": { "prioriteArtistes": {
"type": "string" "type": "string"
@@ -57,4 +57,28 @@ describe('translateLyrics', () => {
expect(result.anglais).toContain('traduit-EN'); expect(result.anglais).toContain('traduit-EN');
expect(result.espagnol).toBeUndefined(); expect(result.espagnol).toBeUndefined();
}); });
it('traduit les langues africaines ajoutées, sans appeler DeepL pour le yoruba', async () => {
global.fetch = vi.fn(async (_url, options) => {
const {target_lang: target} = JSON.parse(options.body);
return fakeDeeplResponse(`traduit-${target}`);
});
const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
log: {error: vi.fn()}
};
const service = createService({strapi});
const result = await service.translateLyrics('Bonjour le monde');
expect(result.swahili).toContain('traduit-SW');
expect(result.lingala).toContain('traduit-LN');
expect(result.wolof).toContain('traduit-WO');
expect(result.hausa).toContain('traduit-HA');
expect(result.yoruba).toBeUndefined();
const calledTargets = global.fetch.mock.calls.map(([, options]) => JSON.parse(options.body).target_lang);
expect(calledTargets).not.toContain('YO');
expect(calledTargets).not.toContain(undefined);
});
}); });
+21 -4
View File
@@ -6,16 +6,33 @@ const { ApplicationError } = require('@strapi/utils').errors;
const LANG_MAP = { const LANG_MAP = {
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' }, fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
// Pas de code DeepL pour le yoruba : traduction manuelle uniquement.
yo: { field: 'yoruba', targetLang: 'yo', userPrompt: 'Translate to Yoruba' },
ln: { field: 'lingala', targetLang: 'ln', userPrompt: 'Translate to Lingala', deeplTarget: 'LN', suffix: '\n\n (DeepL)' },
wo: { field: 'wolof', targetLang: 'wo', userPrompt: 'Translate to Wolof', deeplTarget: 'WO', suffix: '\n\n (DeepL)' },
sw: { field: 'swahili', targetLang: 'sw', userPrompt: 'Translate to Swahili', deeplTarget: 'SW', suffix: '\n\n (DeepL)' },
ha: { field: 'hausa', targetLang: 'ha', userPrompt: 'Translate to Hausa', deeplTarget: 'HA', suffix: '\n\n (DeepL)' },
ar: { field: 'arabe', targetLang: 'ar', userPrompt: 'Translate to Arabic', deeplTarget: 'AR', suffix: '\n\n (DeepL)' },
om: { field: 'oromo', targetLang: 'om', userPrompt: 'Translate to Oromo', deeplTarget: 'OM', suffix: '\n\n (DeepL)' },
ig: { field: 'igbo', targetLang: 'ig', userPrompt: 'Translate to Igbo', deeplTarget: 'IG', suffix: '\n\n (DeepL)' },
zu: { field: 'zoulou', targetLang: 'zu', userPrompt: 'Translate to Zulu', deeplTarget: 'ZU', suffix: '\n\n (DeepL)' },
mg: { field: 'malgache', targetLang: 'mg', userPrompt: 'Translate to Malagasy', deeplTarget: 'MG', suffix: '\n\n (DeepL)' },
xh: { field: 'xhosa', targetLang: 'xh', userPrompt: 'Translate to Xhosa', deeplTarget: 'XH', suffix: '\n\n (DeepL)' },
tn: { field: 'tswana', targetLang: 'tn', userPrompt: 'Translate to Tswana', deeplTarget: 'TN', suffix: '\n\n (DeepL)' },
ts: { field: 'tsonga', targetLang: 'ts', userPrompt: 'Translate to Tsonga', deeplTarget: 'TS', suffix: '\n\n (DeepL)' },
st: { field: 'sesotho', targetLang: 'st', userPrompt: 'Translate to Sesotho', deeplTarget: 'ST', suffix: '\n\n (DeepL)' },
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' }, en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' },
es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' }, es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' }, de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' },
it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' }, it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' },
pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' }, pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' },
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
}; };
const ALL_LANGS = Object.keys(LANG_MAP); const ALL_LANGS = Object.keys(LANG_MAP);
// Langues suivies (schéma + export) mais sans traduction automatique DeepL.
const NO_AUTO_TRANSLATE = new Set(['fr', 'yo']);
function stripMarkdown(text) { function stripMarkdown(text) {
if (!text) return ''; if (!text) return '';
@@ -88,7 +105,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
const result = { francais: parolesFR }; const result = { francais: parolesFR };
for (const lang of ALL_LANGS) { for (const lang of ALL_LANGS) {
if (lang === 'fr') continue; if (NO_AUTO_TRANSLATE.has(lang)) continue;
const { field, deeplTarget, suffix } = LANG_MAP[lang]; const { field, deeplTarget, suffix } = LANG_MAP[lang];
try { try {
const translated = await this.translate('FR', deeplTarget, parolesFR); const translated = await this.translate('FR', deeplTarget, parolesFR);
@@ -203,7 +220,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
async bulkTranslateMissing() { async bulkTranslateMissing() {
const TARGET_LANGS = ALL_LANGS const TARGET_LANGS = ALL_LANGS
.filter(lang => lang !== 'fr') .filter(lang => !NO_AUTO_TRANSLATE.has(lang))
.map(lang => ({ lang, ...LANG_MAP[lang] })); .map(lang => ({ lang, ...LANG_MAP[lang] }));
const pageSize = 100; const pageSize = 100;
+51 -6
View File
@@ -7,9 +7,60 @@
}, },
"options": {}, "options": {},
"attributes": { "attributes": {
"ayisyen": {
"type": "richtext"
},
"francais": { "francais": {
"type": "richtext" "type": "richtext"
}, },
"yoruba": {
"type": "richtext"
},
"lingala": {
"type": "richtext"
},
"wolof": {
"type": "richtext"
},
"swahili": {
"type": "richtext"
},
"hausa": {
"type": "richtext"
},
"arabe": {
"type": "richtext"
},
"oromo": {
"type": "richtext"
},
"igbo": {
"type": "richtext"
},
"zoulou": {
"type": "richtext"
},
"malgache": {
"type": "richtext"
},
"xhosa": {
"type": "richtext"
},
"tswana": {
"type": "richtext"
},
"tsonga": {
"type": "richtext"
},
"sesotho": {
"type": "richtext"
},
"japonais": {
"type": "richtext"
},
"coreen": {
"type": "richtext"
},
"anglais": { "anglais": {
"type": "richtext" "type": "richtext"
}, },
@@ -24,12 +75,6 @@
}, },
"portugais": { "portugais": {
"type": "richtext" "type": "richtext"
},
"japonais": {
"type": "richtext"
},
"coreen": {
"type": "richtext"
} }
} }
} }
@@ -66,6 +66,7 @@ describe('importBokanteComments', () => {
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233'); expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({ expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
status: 'published',
data: expect.objectContaining({ data: expect.objectContaining({
contenu: 'Trè bèl parol !', contenu: 'Trè bèl parol !',
origine: 'activitypub', origine: 'activitypub',
@@ -80,6 +81,7 @@ describe('importBokanteComments', () => {
}); });
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({ expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
documentId: 'doc-7', documentId: 'doc-7',
status: 'published',
data: {commentaires: {connect: expect.any(Array)}} data: {commentaires: {connect: expect.any(Array)}}
}); });
expect(result.imported).toBe(1); expect(result.imported).toBe(1);
+2
View File
@@ -37,6 +37,7 @@ async function importBokanteComments({strapi}) {
} }
const commentaire = await strapi.documents('api::commentaire.commentaire').create({ const commentaire = await strapi.documents('api::commentaire.commentaire').create({
status: 'published',
data: { data: {
contenu: stripHtml(status.content), contenu: stripHtml(status.content),
datePublication: status.created_at, datePublication: status.created_at,
@@ -58,6 +59,7 @@ async function importBokanteComments({strapi}) {
if (newCommentIds.length > 0) { if (newCommentIds.length > 0) {
await strapi.documents('api::parole.parole').update({ await strapi.documents('api::parole.parole').update({
documentId: parole.documentId, documentId: parole.documentId,
status: 'published',
data: {commentaires: {connect: newCommentIds}} data: {commentaires: {connect: newCommentIds}}
}); });
} }
+15
View File
@@ -109,12 +109,27 @@ export interface TradTraductions extends Struct.ComponentSchema {
attributes: { attributes: {
allemand: Schema.Attribute.RichText; allemand: Schema.Attribute.RichText;
anglais: Schema.Attribute.RichText; anglais: Schema.Attribute.RichText;
arabe: Schema.Attribute.RichText;
ayisyen: Schema.Attribute.RichText;
coreen: Schema.Attribute.RichText; coreen: Schema.Attribute.RichText;
espagnol: Schema.Attribute.RichText; espagnol: Schema.Attribute.RichText;
francais: Schema.Attribute.RichText; francais: Schema.Attribute.RichText;
hausa: Schema.Attribute.RichText;
igbo: Schema.Attribute.RichText;
italien: Schema.Attribute.RichText; italien: Schema.Attribute.RichText;
japonais: Schema.Attribute.RichText; japonais: Schema.Attribute.RichText;
lingala: Schema.Attribute.RichText;
malgache: Schema.Attribute.RichText;
oromo: Schema.Attribute.RichText;
portugais: Schema.Attribute.RichText; portugais: Schema.Attribute.RichText;
sesotho: Schema.Attribute.RichText;
swahili: Schema.Attribute.RichText;
tsonga: Schema.Attribute.RichText;
tswana: Schema.Attribute.RichText;
wolof: Schema.Attribute.RichText;
xhosa: Schema.Attribute.RichText;
yoruba: Schema.Attribute.RichText;
zoulou: Schema.Attribute.RichText;
}; };
} }
+1 -1
View File
@@ -506,7 +506,7 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> & origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
Schema.Attribute.Required & Schema.Attribute.Required &
Schema.Attribute.DefaultTo<'local'>; Schema.Attribute.DefaultTo<'local'>;
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>; parole: Schema.Attribute.Relation<'manyToOne', 'api::parole.parole'>;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
remoteId: Schema.Attribute.String & Schema.Attribute.Unique; remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
remoteUrl: Schema.Attribute.String; remoteUrl: Schema.Attribute.String;