Compare commits
20
Commits
2c84ea0f25
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
926fecd125
|
||
|
|
ba5792517b | ||
|
|
b01132f104
|
||
|
|
821b9b96aa
|
||
|
|
842e87543c
|
||
|
|
ecbcf5d86e
|
||
|
|
128c027af1
|
||
|
|
29bdf72640
|
||
|
|
fb60226bd4
|
||
|
|
34f3a7783b
|
||
|
|
03960cc952 | ||
|
|
719b4c7905
|
||
|
|
e416f94061
|
||
|
|
26471d8b0e
|
||
|
|
861e75fde2
|
||
|
|
6c828a2394
|
||
|
|
2a4cea0854
|
||
|
|
648e51fe3c
|
||
|
|
0043a07ec1
|
||
|
|
7e705d3976 |
@@ -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)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const {backupDatabase} = require('../src/utils/backup-database');
|
const {backupDatabase} = require('../src/utils/backup-database');
|
||||||
|
const {importBokanteComments} = require('../src/utils/import-bokante-comments');
|
||||||
|
const {backfillBokanteMirror} = require('../src/utils/backfill-bokante-mirrors');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
myJob: {
|
myJob: {
|
||||||
@@ -19,4 +21,38 @@ module.exports = {
|
|||||||
tz: 'Indian/Reunion',
|
tz: 'Indian/Reunion',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
importBokanteComments: {
|
||||||
|
task: async ({ strapi }) => {
|
||||||
|
if (!process.env.BOKANTE_ACCESS_TOKEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {imported} = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
if (imported > 0) {
|
||||||
|
strapi.log.info(`Import bokante : ${imported} commentaire(s) importé(s).`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
rule: '*/20 * * * *',
|
||||||
|
tz: 'Indian/Reunion',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backfillBokanteMirror: {
|
||||||
|
task: async ({ strapi }) => {
|
||||||
|
if (!process.env.BOKANTE_ACCESS_TOKEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {backfilled, slug} = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
if (backfilled) {
|
||||||
|
strapi.log.info(`Backfill bokante : parole "${slug}" publiée.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
rule: process.env.BOKANTE_BACKFILL_CRON || '0 * * * *',
|
||||||
|
tz: 'Indian/Reunion',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,34 @@
|
|||||||
},
|
},
|
||||||
"parole": {
|
"parole": {
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"relation": "oneToOne",
|
"relation": "manyToOne",
|
||||||
"target": "api::parole.parole"
|
"target": "api::parole.parole",
|
||||||
|
"inversedBy": "commentaires"
|
||||||
|
},
|
||||||
|
"origine": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["local", "activitypub"],
|
||||||
|
"default": "local",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"auteurNom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auteurHandle": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auteurAvatarUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auteurProfilUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"remoteId": {
|
||||||
|
"type": "string",
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
"remoteUrl": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,113 @@ describe('beforeUpdate — notifications Telegram/Revolt', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('afterCreate — publication miroir bokante', () => {
|
||||||
|
const originalEnv = {...process.env};
|
||||||
|
const bokanteMastodon = require('../../../../../utils/bokante-mastodon');
|
||||||
|
const originalCreateStatus = bokanteMastodon.createStatus;
|
||||||
|
|
||||||
|
function buildStrapi() {
|
||||||
|
const dbQuery = {
|
||||||
|
findOne: vi.fn(async () => null),
|
||||||
|
updateMany: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {query: vi.fn(() => dbQuery)},
|
||||||
|
plugins: {email: {services: {email: {send: vi.fn()}}}},
|
||||||
|
log: {error: vi.fn()}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvent(resultOverrides = {}) {
|
||||||
|
return {
|
||||||
|
params: {data: {titre: 'Mon titre'}},
|
||||||
|
result: {
|
||||||
|
documentId: 'doc-1',
|
||||||
|
titre: 'Mon titre',
|
||||||
|
slug: 'mon-titre',
|
||||||
|
publishedAt: '2026-07-04T00:00:00.000Z',
|
||||||
|
bokanteStatusId: null,
|
||||||
|
...resultOverrides
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = {...originalEnv};
|
||||||
|
bokanteMastodon.createStatus = originalCreateStatus;
|
||||||
|
delete global.strapi;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publie un statut miroir et synchronise bokanteStatusId sur le documentId', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent());
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Mon titre');
|
||||||
|
expect(strapiMock.db.query('api::parole.parole').updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {documentId: 'doc-1'},
|
||||||
|
data: {bokanteStatusId: '112233'}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne publie rien si la ligne créée n\'est pas publiée (simple brouillon)', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent({publishedAt: null}));
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne republie pas si bokanteStatusId existe déjà', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent({bokanteStatusId: '112233'}));
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne publie rien si BOKANTE_ACCESS_TOKEN n\'est pas configuré', async () => {
|
||||||
|
delete process.env.BOKANTE_ACCESS_TOKEN;
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent());
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n\'interrompt pas la création quand bokante échoue', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await expect(afterCreate(buildEvent())).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('bokante'));
|
||||||
|
expect(strapiMock.db.query('api::parole.parole').updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('beforeUpdate — createdBy/updatedBy', () => {
|
describe('beforeUpdate — createdBy/updatedBy', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete global.strapi;
|
delete global.strapi;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const slugify = require('slugify');
|
const slugify = require('slugify');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const bokanteMastodon = require('../../../../utils/bokante-mastodon');
|
||||||
|
|
||||||
const utils = require('@strapi/utils');
|
const utils = require('@strapi/utils');
|
||||||
const { ApplicationError } = utils.errors;
|
const { ApplicationError } = utils.errors;
|
||||||
@@ -272,6 +273,27 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
afterCreate: async event => {
|
afterCreate: async event => {
|
||||||
const {data} = event.params;
|
const {data} = event.params;
|
||||||
|
|
||||||
|
// Avec draftAndPublish, "publier" crée une nouvelle ligne (la version publiée)
|
||||||
|
// au lieu de mettre à jour la ligne existante : c'est ici, et non dans
|
||||||
|
// beforeUpdate, qu'un événement de publication est détectable.
|
||||||
|
if (event.result?.publishedAt && !event.result?.bokanteStatusId && process.env.BOKANTE_ACCESS_TOKEN) {
|
||||||
|
try {
|
||||||
|
const status = await bokanteMastodon.createStatus(
|
||||||
|
`"${event.result.titre}" — nouvelle parole sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${event.result.slug}`
|
||||||
|
);
|
||||||
|
// Synchronise brouillon et version publiée pour éviter une republication
|
||||||
|
// en double au prochain cycle dépublier/republier (qui recrée la ligne
|
||||||
|
// publiée à partir du brouillon).
|
||||||
|
await strapi.db.query('api::parole.parole').updateMany({
|
||||||
|
where: {documentId: event.result.documentId},
|
||||||
|
data: {bokanteStatusId: status.id}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(`Publication bokante : ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await jwennUserEpiId(data?.user?.id);
|
const user = await jwennUserEpiId(data?.user?.id);
|
||||||
const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
|
const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
|
||||||
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
|
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -158,6 +159,9 @@
|
|||||||
"type": "component",
|
"type": "component",
|
||||||
"component": "kit.lyen",
|
"component": "kit.lyen",
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
|
},
|
||||||
|
"bokanteStatusId": {
|
||||||
|
"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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {describe, it, expect, vi, afterEach} from 'vitest';
|
||||||
|
import {backfillBokanteMirror} from '../backfill-bokante-mirrors.js';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('../bokante-mastodon.js');
|
||||||
|
|
||||||
|
function buildStrapi({paroles = []} = {}) {
|
||||||
|
const updateMany = vi.fn();
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
query: () => ({
|
||||||
|
findMany: vi.fn(async ({where, orderBy, limit}) => {
|
||||||
|
expect(where).toEqual({
|
||||||
|
publishedAt: {$notNull: true},
|
||||||
|
bokanteStatusId: {$null: true}
|
||||||
|
});
|
||||||
|
expect(orderBy).toEqual({publishedAt: 'asc'});
|
||||||
|
expect(limit).toBe(1);
|
||||||
|
return paroles.slice(0, limit);
|
||||||
|
}),
|
||||||
|
updateMany
|
||||||
|
})
|
||||||
|
},
|
||||||
|
log: {error: vi.fn(), info: vi.fn()}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('backfillBokanteMirror', () => {
|
||||||
|
const originalCreateStatus = bokanteMastodon.createStatus;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
bokanteMastodon.createStatus = originalCreateStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publie le miroir pour la parole publiée la plus ancienne sans bokanteStatusId', async () => {
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
|
||||||
|
const parole = {documentId: 'doc-1', titre: 'Vieux titre', slug: 'vieux-titre'};
|
||||||
|
const strapi = buildStrapi({paroles: [parole]});
|
||||||
|
|
||||||
|
const result = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Vieux titre');
|
||||||
|
expect(bokanteMastodon.createStatus.mock.calls[0][0]).not.toContain('nouvelle parole');
|
||||||
|
expect(strapi.db.query().updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {documentId: 'doc-1'},
|
||||||
|
data: {bokanteStatusId: '112233'}
|
||||||
|
});
|
||||||
|
expect(result).toEqual({backfilled: true, slug: 'vieux-titre'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne fait rien si le catalogue est déjà rattrapé', async () => {
|
||||||
|
bokanteMastodon.createStatus = vi.fn();
|
||||||
|
const strapi = buildStrapi({paroles: []});
|
||||||
|
|
||||||
|
const result = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({backfilled: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n\'interrompt rien si bokante échoue, et ne marque pas la parole comme traitée', async () => {
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
const parole = {documentId: 'doc-1', titre: 'Titre', slug: 'titre'};
|
||||||
|
const strapi = buildStrapi({paroles: [parole]});
|
||||||
|
|
||||||
|
const result = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('titre'));
|
||||||
|
expect(strapi.db.query().updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({backfilled: false});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest';
|
||||||
|
import {createStatus, getStatusContext} from '../bokante-mastodon.js';
|
||||||
|
|
||||||
|
describe('bokante-mastodon', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalEnv = {...process.env};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.BOKANTE_INSTANCE_URL = 'https://bokante.o-k-i.net';
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'test-token';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
process.env = {...originalEnv};
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createStatus', () => {
|
||||||
|
it('poste le statut avec le bon endpoint, jeton et corps', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await createStatus('Nouvelle parole : "Titre"');
|
||||||
|
|
||||||
|
const [url, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
expect(options.headers.Authorization).toBe('Bearer test-token');
|
||||||
|
expect(JSON.parse(options.body)).toEqual({
|
||||||
|
status: 'Nouvelle parole : "Titre"',
|
||||||
|
visibility: 'public'
|
||||||
|
});
|
||||||
|
expect(result).toEqual({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 422,
|
||||||
|
text: async () => 'Validation Failed'
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(createStatus('texte')).rejects.toThrow('Mastodon 422: Validation Failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatusContext', () => {
|
||||||
|
it('récupère le contexte du statut avec le bon endpoint et jeton', async () => {
|
||||||
|
const context = {ancestors: [], descendants: [{id: '1', content: '<p>coucou</p>'}]};
|
||||||
|
global.fetch = vi.fn(async () => ({ok: true, json: async () => context}));
|
||||||
|
|
||||||
|
const result = await getStatusContext('112233');
|
||||||
|
|
||||||
|
const [url, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses/112233/context');
|
||||||
|
expect(options.headers.Authorization).toBe('Bearer test-token');
|
||||||
|
expect(result).toEqual(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: async () => 'Record not found'
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(getStatusContext('inconnu')).rejects.toThrow('Mastodon 404: Record not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {describe, it, expect, vi, afterEach} from 'vitest';
|
||||||
|
import {importBokanteComments} from '../import-bokante-comments.js';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('../bokante-mastodon.js');
|
||||||
|
|
||||||
|
function buildStatus(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'status-1',
|
||||||
|
content: '<p>Trè bèl parol !</p>',
|
||||||
|
created_at: '2026-07-04T12:00:00.000Z',
|
||||||
|
url: 'https://bokante.o-k-i.net/@quelqun/status-1',
|
||||||
|
account: {
|
||||||
|
display_name: 'Quelqu\'un',
|
||||||
|
acct: 'quelqun@mastodon.social',
|
||||||
|
avatar: 'https://mastodon.social/avatars/quelqun.png',
|
||||||
|
url: 'https://mastodon.social/@quelqun'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStrapi({paroles, existingRemoteIds = [], createImpl}) {
|
||||||
|
const commentaireDocuments = {
|
||||||
|
create: createImpl || vi.fn(async ({data}) => ({id: Math.floor(Math.random() * 10_000), ...data}))
|
||||||
|
};
|
||||||
|
const paroleDocuments = {update: vi.fn(async () => ({}))};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
query: uid => {
|
||||||
|
if (uid === 'api::parole.parole') {
|
||||||
|
return {findMany: vi.fn(async () => paroles)};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uid === 'api::commentaire.commentaire') {
|
||||||
|
return {
|
||||||
|
findOne: vi.fn(async ({where}) => (existingRemoteIds.includes(where.remoteId) ? {id: 1} : null))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
documents: uid => {
|
||||||
|
if (uid === 'api::commentaire.commentaire') return commentaireDocuments;
|
||||||
|
if (uid === 'api::parole.parole') return paroleDocuments;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
log: {error: vi.fn()}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('importBokanteComments', () => {
|
||||||
|
const originalGetStatusContext = bokanteMastodon.getStatusContext;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
bokanteMastodon.getStatusContext = originalGetStatusContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importe les nouveaux commentaires et les connecte à la parole', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus()]}));
|
||||||
|
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
|
||||||
|
const strapi = buildStrapi({paroles});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
|
||||||
|
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
|
||||||
|
status: 'published',
|
||||||
|
data: expect.objectContaining({
|
||||||
|
contenu: 'Trè bèl parol !',
|
||||||
|
origine: 'activitypub',
|
||||||
|
auteurNom: 'Quelqu\'un',
|
||||||
|
auteurHandle: '@quelqun@mastodon.social',
|
||||||
|
auteurAvatarUrl: 'https://mastodon.social/avatars/quelqun.png',
|
||||||
|
auteurProfilUrl: 'https://mastodon.social/@quelqun',
|
||||||
|
remoteId: 'status-1',
|
||||||
|
remoteUrl: 'https://bokante.o-k-i.net/@quelqun/status-1',
|
||||||
|
parole: 7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
|
||||||
|
documentId: 'doc-7',
|
||||||
|
status: 'published',
|
||||||
|
data: {commentaires: {connect: expect.any(Array)}}
|
||||||
|
});
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n\'importe pas un commentaire déjà présent (déduplication par remoteId)', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({id: 'deja-la'})]}));
|
||||||
|
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
|
||||||
|
const strapi = buildStrapi({paroles, existingRemoteIds: ['deja-la']});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
|
||||||
|
expect(strapi.documents('api::parole.parole').update).not.toHaveBeenCalled();
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore les statuts marqués sensitive', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({sensitive: true})]}));
|
||||||
|
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
|
||||||
|
const strapi = buildStrapi({paroles});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continue les autres paroles quand une requête bokante échoue', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async statusId => {
|
||||||
|
if (statusId === 'echoue') {
|
||||||
|
throw new Error('Mastodon 500: boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {descendants: [buildStatus({id: 'ok-1'})]};
|
||||||
|
});
|
||||||
|
|
||||||
|
const paroles = [
|
||||||
|
{id: 1, documentId: 'doc-1', slug: 'echoue', bokanteStatusId: 'echoue'},
|
||||||
|
{id: 2, documentId: 'doc-2', slug: 'reussi', bokanteStatusId: 'ok'}
|
||||||
|
];
|
||||||
|
const strapi = buildStrapi({paroles});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('echoue'));
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('./bokante-mastodon');
|
||||||
|
|
||||||
|
async function backfillBokanteMirror({strapi}) {
|
||||||
|
const [parole] = await strapi.db.query('api::parole.parole').findMany({
|
||||||
|
where: {
|
||||||
|
publishedAt: {$notNull: true},
|
||||||
|
bokanteStatusId: {$null: true}
|
||||||
|
},
|
||||||
|
orderBy: {publishedAt: 'asc'},
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parole) {
|
||||||
|
return {backfilled: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await bokanteMastodon.createStatus(
|
||||||
|
`"${parole.titre}" — à (re)découvrir sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${parole.slug}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await strapi.db.query('api::parole.parole').updateMany({
|
||||||
|
where: {documentId: parole.documentId},
|
||||||
|
data: {bokanteStatusId: status.id}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {backfilled: true, slug: parole.slug};
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(`Backfill bokante (${parole.slug}) : ${err.message}`);
|
||||||
|
return {backfilled: false};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {backfillBokanteMirror};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return {
|
||||||
|
instanceUrl: process.env.BOKANTE_INSTANCE_URL || 'https://bokante.o-k-i.net',
|
||||||
|
accessToken: process.env.BOKANTE_ACCESS_TOKEN
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mastodonFetch(url, options) {
|
||||||
|
const {accessToken} = getConfig();
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(`Mastodon ${response.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStatus(text, {visibility = 'public'} = {}) {
|
||||||
|
const {instanceUrl} = getConfig();
|
||||||
|
return mastodonFetch(`${instanceUrl}/api/v1/statuses`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({status: text, visibility})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatusContext(statusId) {
|
||||||
|
const {instanceUrl} = getConfig();
|
||||||
|
return mastodonFetch(`${instanceUrl}/api/v1/statuses/${statusId}/context`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {createStatus, getStatusContext};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('./bokante-mastodon');
|
||||||
|
|
||||||
|
function stripHtml(html) {
|
||||||
|
return (html || '').replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBokanteComments({strapi}) {
|
||||||
|
const paroles = await strapi.db.query('api::parole.parole').findMany({
|
||||||
|
where: {bokanteStatusId: {$notNull: true}}
|
||||||
|
});
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
for (const parole of paroles) {
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await bokanteMastodon.getStatusContext(parole.bokanteStatusId);
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(`Import bokante (${parole.slug}) : ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCommentIds = [];
|
||||||
|
|
||||||
|
for (const status of context.descendants || []) {
|
||||||
|
if (status.sensitive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await strapi.db.query('api::commentaire.commentaire').findOne({
|
||||||
|
where: {remoteId: status.id}
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentaire = await strapi.documents('api::commentaire.commentaire').create({
|
||||||
|
status: 'published',
|
||||||
|
data: {
|
||||||
|
contenu: stripHtml(status.content),
|
||||||
|
datePublication: status.created_at,
|
||||||
|
origine: 'activitypub',
|
||||||
|
auteurNom: status.account?.display_name,
|
||||||
|
auteurHandle: status.account?.acct ? `@${status.account.acct}` : undefined,
|
||||||
|
auteurAvatarUrl: status.account?.avatar,
|
||||||
|
auteurProfilUrl: status.account?.url,
|
||||||
|
remoteId: status.id,
|
||||||
|
remoteUrl: status.url,
|
||||||
|
parole: parole.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newCommentIds.push(commentaire.id);
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCommentIds.length > 0) {
|
||||||
|
await strapi.documents('api::parole.parole').update({
|
||||||
|
documentId: parole.documentId,
|
||||||
|
status: 'published',
|
||||||
|
data: {commentaires: {connect: newCommentIds}}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {imported};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {importBokanteComments};
|
||||||
Vendored
+15
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+11
-1
@@ -488,6 +488,10 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
draftAndPublish: true;
|
draftAndPublish: true;
|
||||||
};
|
};
|
||||||
attributes: {
|
attributes: {
|
||||||
|
auteurAvatarUrl: Schema.Attribute.String;
|
||||||
|
auteurHandle: Schema.Attribute.String;
|
||||||
|
auteurNom: Schema.Attribute.String;
|
||||||
|
auteurProfilUrl: Schema.Attribute.String;
|
||||||
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
|
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
|
||||||
createdAt: Schema.Attribute.DateTime;
|
createdAt: Schema.Attribute.DateTime;
|
||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
@@ -499,8 +503,13 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
'api::commentaire.commentaire'
|
'api::commentaire.commentaire'
|
||||||
> &
|
> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
|
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
|
||||||
|
Schema.Attribute.Required &
|
||||||
|
Schema.Attribute.DefaultTo<'local'>;
|
||||||
|
parole: Schema.Attribute.Relation<'manyToOne', 'api::parole.parole'>;
|
||||||
publishedAt: Schema.Attribute.DateTime;
|
publishedAt: Schema.Attribute.DateTime;
|
||||||
|
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
|
||||||
|
remoteUrl: Schema.Attribute.String;
|
||||||
updatedAt: Schema.Attribute.DateTime;
|
updatedAt: Schema.Attribute.DateTime;
|
||||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
@@ -526,6 +535,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
|
|||||||
attributes: {
|
attributes: {
|
||||||
annee: Schema.Attribute.Integer;
|
annee: Schema.Attribute.Integer;
|
||||||
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
|
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
|
||||||
|
bokanteStatusId: Schema.Attribute.String;
|
||||||
commentaires: Schema.Attribute.Relation<
|
commentaires: Schema.Attribute.Relation<
|
||||||
'oneToMany',
|
'oneToMany',
|
||||||
'api::commentaire.commentaire'
|
'api::commentaire.commentaire'
|
||||||
|
|||||||
Reference in New Issue
Block a user