28 Commits
Author SHA1 Message Date
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
cedric 719b4c7905 chore: régénérer les types Strapi (champs bokante)
Déploiement API BETA / build (push) Successful in 2m20s
Vérification PR / build (pull_request) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 46s
Vérification PR / deploy-beta (pull_request) Successful in 47s
2026-07-05 01:18:44 +04:00
cedric e416f94061 feat: backfiller le catalogue existant, une parole/heure 2026-07-05 01:07:31 +04:00
cedric 26471d8b0e fix: déplacer le miroir bokante de beforeUpdate vers afterCreate
Avec draftAndPublish sur Strapi 5, publier une entrée crée une
nouvelle ligne (document-service publishEntry -> createEntry) au lieu
de mettre à jour la ligne existante : beforeUpdate ne se déclenche
donc jamais sur une vraie action Publier, seulement sur l'édition
d'une entrée déjà publiée. Le miroir bokante est maintenant posté
dans afterCreate, sur la condition result.publishedAt, avec
synchronisation brouillon/publié pour rester idempotent à travers les
cycles dépublier/republier.
2026-07-05 00:59:59 +04:00
cedric 861e75fde2 feat: planifier l'import des commentaires bokante (20 min) 2026-07-04 23:39:25 +04:00
cedric 6c828a2394 feat: importer les réponses bokante en commentaires 2026-07-04 23:38:41 +04:00
cedric 2a4cea0854 feat: publier un statut miroir bokante à la publication 2026-07-04 23:37:15 +04:00
cedric 648e51fe3c feat: client HTTP minimal pour l'API Mastodon de bokante 2026-07-04 23:33:57 +04:00
cedric 0043a07ec1 feat: schéma pour les commentaires fédérés 2026-07-04 23:31:09 +04:00
cedric 7e705d3976 Merge pull request 'Audit sécurité/qualité : corrections critiques, tests, CI et lint' (#4) from fix/audit-2026-07-04 into master
Déploiement API BETA / deploy (push) Successful in 45s
Déploiement API PROD / deploy (push) Successful in 1m25s
Déploiement API BETA / build (push) Successful in 2m19s
Déploiement API PROD / build (push) Successful in 2m27s
Reviewed-on: #4
2026-07-04 17:01:30 +00:00
cedric 2c84ea0f25 fix: corriger les erreurs ESLint restantes après activation
Déploiement API BETA / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 1m37s
Vérification PR / build (pull_request) Successful in 2m23s
Vérification PR / deploy-beta (pull_request) Successful in 48s
- retirer les paramètres/variables inutilisés (result, ctx/next, qs)
- corriger l'indentation tabs/espaces mélangés dans src/admin/app.js
- retirer les échappements regex inutiles dans stripMarkdown
- remplacer while(true) par for(;;) (faux positif no-constant-condition)
2026-07-04 20:01:16 +04:00
cedric 2224c8f3bb chore: activer ESLint sur le backend
Installe eslint, ajoute le script lint, modernise le parser
(retrait de babel-eslint obsolète) et applique l'autofix
(points-virgules manquants sur l'ensemble du code, conformément
à la règle "semi" déjà présente dans .eslintrc mais jamais
appliquée faute d'ESLint installé et d'un script pour l'exécuter).
2026-07-04 20:00:51 +04:00
cedric 90c048a282 fix: whitelister les champs autorisés sur create/update (mass assignment) 2026-07-04 15:08:19 +04:00
cedric 1d54d287e1 fix: retirer createdBy/updatedBy du payload dans beforeUpdate 2026-07-04 15:04:18 +04:00
cedric 3b9b28d326 fix: ne plus écraser les commentaires existants d'une parole 2026-07-04 14:59:58 +04:00
cedric 4ee7ed3e5e fix: corriger la signature de parole.findOne (ctx au lieu de documentId) 2026-07-04 14:58:43 +04:00
cedric 0afa9251d6 ci: exécuter les tests dans deploy-beta et deploy-prod 2026-07-04 11:39:53 +04:00
cedric 250ae822ea chore: retirer les secrets de fallback hardcodés dans admin.js 2026-07-04 11:39:45 +04:00
cedric b36f53f022 chore: supprimer les dossiers legacy extensions/ et components/ 2026-07-04 11:39:36 +04:00
cedric e4187a697a fix: envoyer le commentaire en texte brut (éviter l'injection HTML) 2026-07-04 11:38:56 +04:00
59 changed files with 2063 additions and 3492 deletions
+2 -6
View File
@@ -1,17 +1,13 @@
{ {
"parser": "babel-eslint",
"extends": "eslint:recommended", "extends": "eslint:recommended",
"env": { "env": {
"commonjs": true, "commonjs": true,
"es6": true, "es2022": true,
"node": true, "node": true,
"browser": false "browser": false
}, },
"parserOptions": { "parserOptions": {
"ecmaFeatures": { "ecmaVersion": 2022,
"experimentalObjectRestSpread": true,
"jsx": false
},
"sourceType": "module" "sourceType": "module"
}, },
"globals": { "globals": {
+3
View File
@@ -20,6 +20,9 @@ jobs:
- name: Installer les dépendances - name: Installer les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer les tests
run: yarn test
- name: Build Strapi - name: Build Strapi
run: yarn build run: yarn build
+3
View File
@@ -22,6 +22,9 @@ jobs:
- name: Installer les dépendances - name: Installer les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer les tests
run: yarn test
- name: Build Strapi - name: Build Strapi
run: yarn build run: yarn build
+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)
-29
View File
@@ -1,29 +0,0 @@
{
"collectionName": "components_store_stores",
"info": {
"name": "Album",
"icon": "music",
"description": ""
},
"options": {},
"attributes": {
"url": {
"type": "string",
"required": true
},
"boutik": {
"type": "enumeration",
"enum": [
"Tidal",
"Spotify",
"Deezer",
"Qobuz",
"Youtubemusic",
"Applemusic",
"Amazon",
"Soundcloud"
],
"required": true
}
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"collectionName": "components_trad_traductions",
"info": {
"name": "traductions",
"icon": "spell-check",
"description": ""
},
"options": {},
"attributes": {
"francais": {
"type": "richtext"
},
"english": {
"type": "richtext"
},
"espagnol": {
"type": "richtext"
},
"deutsch": {
"type": "richtext"
},
"italiano": {
"type": "richtext"
}
}
}
-28
View File
@@ -1,28 +0,0 @@
{
"collectionName": "components_url_liens",
"info": {
"name": "liens",
"icon": "link",
"description": ""
},
"options": {},
"attributes": {
"url": {
"type": "string",
"required": true
},
"sit": {
"type": "enumeration",
"enum": [
"Youtube",
"Peertube",
"Dailymotion",
"Vimeo",
"File",
"Lbry",
"Rumble"
],
"required": true
}
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ const forgotPasswordTemplate = require('./email-templates/forgot-password');
module.exports = ({ env }) => ({ module.exports = ({ env }) => ({
url: env('STRAPI_ADMIN_URL', '/admin'), url: env('STRAPI_ADMIN_URL', '/admin'),
apiToken: { apiToken: {
salt: env('API_TOKEN_SALT', 'd9b0df66ff97a666027e665707b4e3e7'), salt: env('API_TOKEN_SALT'),
}, },
auth: { auth: {
secret: env('ADMIN_JWT_SECRET', '77b2c87dbab4e1697bec244226fbd1b3'), secret: env('ADMIN_JWT_SECRET'),
}, },
forgotPassword: { forgotPassword: {
from: env('SMTP_FROM'), from: env('SMTP_FROM'),
+36
View File
@@ -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',
},
},
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
const subject = `Réinitialiser le mot de passe`; const subject = 'Réinitialiser le mot de passe';
const html = `<p>Bèl bonjou <%= user.firstname %></p> const html = `<p>Bèl bonjou <%= user.firstname %></p>
<p>Nous avons appris que tu a perdu ton mot de passe. Nous en sommes désolés ! </p> <p>Nous avons appris que tu a perdu ton mot de passe. Nous en sommes désolés ! </p>
+1 -1
View File
@@ -16,4 +16,4 @@ module.exports = ({env}) => ({
defaultReplyTo: env('SMTP_REPLY_TO'), defaultReplyTo: env('SMTP_REPLY_TO'),
}, },
}, },
}) });
+1 -1
View File
@@ -1,4 +1,4 @@
const cronTasks = require("./cron-task"); const cronTasks = require('./cron-task');
module.exports = ({ env }) => ({ module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'), host: env('HOST', '0.0.0.0'),
View File
@@ -1,43 +0,0 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.0.0",
"title": "#OKi API Dokiman",
"description": "",
"termsOfService": null,
"contact": {
"name": "#OKi",
"email": "kontak@o-k-i.net",
"url": "https://o-k-i.net"
},
"license": null
},
"x-strapi-config": {
"path": "/dokiman",
"showGeneratedFiles": true,
"pluginsForWhichToGenerateDoc": []
},
"servers": [
{
"url": "https://api.o-k-i.net",
"description": "Production server"
},
{
"url": "http://localhost:1337",
"description": "Development server"
},
{
"url": "http://localhost:1337",
"description": "Staging server"
}
],
"externalDocs": null,
"security": [
{
"bearerAuth": []
}
],
"paths": {},
"tags": [],
"components": {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-136
View File
@@ -1,136 +0,0 @@
<!DOCTYPE html><html><head>
<title>Login - Documentation</title>
<link href="https://fonts.googleapis.com/css?family=Lato:400,700" rel="stylesheet">
<style>
html {
font-size: 62.5%;
height: 100%;
margin: 0;
padding: 0;
}
body {
height: 100%;
margin: 0;
background-color: #ffffff;
font-family: 'Lato';
font-size: 1.4rem;
font-weight: 400;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login {
height: 100%;
background-color: #F6F9FC;
}
.login .login-form {
height: calc(100% - 70px);
padding: 68px 0 0;
text-align: center;
}
.login .login-form form {
position: relative;
max-width: 460px;
padding: 26px 30px;
margin: 55px auto 0;
background-color: #ffffff;
border-radius: 3px;
box-shadow: 0px 2px 4px rgba(91, 107, 174, .15);
text-align: center;
}
.login .login-form form:before {
position: absolute;
content: '';
top: 0px;
left: 0;
display: inline-block;
width: 100%;
height: 2px;
background-color: #2B66CC;
}
.login .login-form form .error {
display: block;
color: #FF4E00;
padding-bottom: 20px;
}
.login .login-form .sub-title {
margin-top: 35px;
font-size: 1.6rem;
font-weight: 400;
}
.login .login-form .logo{
max-height: 40px;
}
.login .login-form form label {
display: block;
margin-bottom: 18px;
width: 100%;
text-align: left;
font-weight: 600;
}
.login .login-form form input {
outline: none;
width: calc(100% - 30px);
height: 36px;
padding: 0 15px;
border: 1px solid #ECECEC;
border-radius: 2px;
margin-bottom: 20px;
line-height: 36px;
text-align: left;
}
.login .login-form form input[type=submit] {
cursor: pointer;
display: inline-block;
width: auto;
margin: 12px auto 0;
padding: 0 75px;
background: transparent;
border-radius: 36px;
border: 1px solid #2B66CC;
color: #2B66CC;
text-transform: uppercase;
font-size: 1.4rem;
font-weight: 700;
transition: all .2s ease-out;
}
.login .login-form form input[type=submit]:hover {
background: #2B66CC;
color: #ffffff;
}
</style>
</head>
<body>
<div class="login">
<section class="login-form">
<div class="container">
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12">
<img alt="Strapi logo" class="logo" src="https://strapi.io/assets/images/logo_login.png">
<h2 class="sub-title">Enter the password to access the documentation.</h2>
<form method="post" action="/dokiman/login">
<span class="error">Wrong password...</span>
<label>Password</label>
<input type="password" name="password" placeholder="•••••••••">
<input type="submit" value="Login">
</form>
</div>
</div>
</div>
</section>
</div>
</body></html>
@@ -1,3 +0,0 @@
module.exports = {
jwtSecret: process.env.JWT_SECRET || '3527426b-d513-44a5-b4c8-ee16494bab0d'
};
@@ -1,78 +0,0 @@
{
"kind": "collectionType",
"collectionName": "users-permissions_user",
"info": {
"name": "user",
"description": ""
},
"options": {
"draftAndPublish": false,
"timestamps": true,
"privateAttributes": [
"createdAt",
"updatedAt",
"created_at",
"updated_at"
]
},
"attributes": {
"username": {
"type": "string",
"minLength": 3,
"unique": true,
"configurable": false,
"required": true
},
"email": {
"type": "email",
"minLength": 6,
"configurable": false,
"required": true,
"private": true
},
"provider": {
"type": "string",
"configurable": false,
"private": true
},
"password": {
"type": "password",
"minLength": 6,
"configurable": false,
"private": true
},
"resetPasswordToken": {
"type": "string",
"configurable": false,
"private": true
},
"confirmationToken": {
"type": "string",
"configurable": false,
"private": true
},
"confirmed": {
"type": "boolean",
"default": false,
"configurable": false,
"private": true
},
"blocked": {
"type": "boolean",
"default": false,
"configurable": false,
"private": true
},
"role": {
"model": "role",
"via": "users",
"plugin": "users-permissions",
"configurable": false,
"private": true
},
"canAutoTranslate": {
"type": "boolean",
"private": false
}
}
}
+3 -1
View File
@@ -18,9 +18,11 @@
"start": "strapi start", "start": "strapi start",
"build": "strapi build", "build": "strapi build",
"strapi": "strapi", "strapi": "strapi",
"test": "vitest run" "test": "vitest run",
"lint": "eslint ."
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.7.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
}, },
"dependencies": { "dependencies": {
@@ -1,66 +1,66 @@
import {describe, it, expect, vi, afterEach} from 'vitest' import {describe, it, expect, vi, afterEach} from 'vitest';
async function loadLifecycles(strapiMock) { async function loadLifecycles(strapiMock) {
vi.resetModules() vi.resetModules();
global.strapi = strapiMock global.strapi = strapiMock;
const mod = await import('../lifecycles.js') const mod = await import('../lifecycles.js');
return mod return mod;
} }
describe('artiste afterUpdate — cascade de renommage des slugs', () => { describe('artiste afterUpdate — cascade de renommage des slugs', () => {
afterEach(() => { afterEach(() => {
delete global.strapi delete global.strapi;
}) });
it("renomme le slug des paroles de l'artiste quand son alias change", async () => { it('renomme le slug des paroles de l\'artiste quand son alias change', async () => {
const artisteFindOne = vi.fn(async () => ({ const artisteFindOne = vi.fn(async () => ({
id: 5, id: 5,
paroles: [{id: 10}, {id: 11}] paroles: [{id: 10}, {id: 11}]
})) }));
const paroleFindMany = vi.fn(async () => [ const paroleFindMany = vi.fn(async () => [
{id: 10, titre: 'Titre 1', slug: 'ancien-alias-titre-1', artistes: [{alias: 'nouvel-alias'}]}, {id: 10, titre: 'Titre 1', slug: 'ancien-alias-titre-1', artistes: [{alias: 'nouvel-alias'}]},
{id: 11, titre: 'Titre 2', slug: 'nouvel-alias-titre-2', artistes: [{alias: 'nouvel-alias'}]} {id: 11, titre: 'Titre 2', slug: 'nouvel-alias-titre-2', artistes: [{alias: 'nouvel-alias'}]}
]) ]);
const paroleUpdate = vi.fn(async () => {}) const paroleUpdate = vi.fn(async () => {});
const strapiMock = { const strapiMock = {
db: { db: {
query: vi.fn(uid => { query: vi.fn(uid => {
if (uid === 'api::artiste.artiste') return {findOne: artisteFindOne} if (uid === 'api::artiste.artiste') return {findOne: artisteFindOne};
if (uid === 'api::parole.parole') return {findMany: paroleFindMany, update: paroleUpdate} if (uid === 'api::parole.parole') return {findMany: paroleFindMany, update: paroleUpdate};
throw new Error(`unexpected uid: ${uid}`) throw new Error(`unexpected uid: ${uid}`);
}) })
} }
} };
const {afterUpdate} = await loadLifecycles(strapiMock) const {afterUpdate} = await loadLifecycles(strapiMock);
await afterUpdate({result: {id: 5}}) await afterUpdate({result: {id: 5}});
expect(artisteFindOne).toHaveBeenCalledWith({where: {id: 5}, populate: ['paroles']}) expect(artisteFindOne).toHaveBeenCalledWith({where: {id: 5}, populate: ['paroles']});
expect(paroleFindMany).toHaveBeenCalledWith({where: {id: {$in: [10, 11]}}, populate: ['artistes']}) expect(paroleFindMany).toHaveBeenCalledWith({where: {id: {$in: [10, 11]}}, populate: ['artistes']});
expect(paroleUpdate).toHaveBeenCalledTimes(1) expect(paroleUpdate).toHaveBeenCalledTimes(1);
expect(paroleUpdate).toHaveBeenCalledWith({where: {id: 10}, data: {slug: 'nouvel-alias-titre-1'}}) expect(paroleUpdate).toHaveBeenCalledWith({where: {id: 10}, data: {slug: 'nouvel-alias-titre-1'}});
}) });
it("ne fait rien quand l'artiste n'a aucune parole", async () => { it('ne fait rien quand l\'artiste n\'a aucune parole', async () => {
const artisteFindOne = vi.fn(async () => ({id: 5, paroles: []})) const artisteFindOne = vi.fn(async () => ({id: 5, paroles: []}));
const paroleFindMany = vi.fn() const paroleFindMany = vi.fn();
const strapiMock = { const strapiMock = {
db: { db: {
query: vi.fn(uid => { query: vi.fn(uid => {
if (uid === 'api::artiste.artiste') return {findOne: artisteFindOne} if (uid === 'api::artiste.artiste') return {findOne: artisteFindOne};
if (uid === 'api::parole.parole') return {findMany: paroleFindMany} if (uid === 'api::parole.parole') return {findMany: paroleFindMany};
throw new Error(`unexpected uid: ${uid}`) throw new Error(`unexpected uid: ${uid}`);
}) })
} }
} };
const {afterUpdate} = await loadLifecycles(strapiMock) const {afterUpdate} = await loadLifecycles(strapiMock);
await afterUpdate({result: {id: 5}}) await afterUpdate({result: {id: 5}});
expect(paroleFindMany).not.toHaveBeenCalled() expect(paroleFindMany).not.toHaveBeenCalled();
}) });
}) });
@@ -1,60 +1,60 @@
'use strict'; 'use strict';
const { ApplicationError } = require("@strapi/utils").errors const { ApplicationError } = require('@strapi/utils').errors;
const slugify = require('slugify') const slugify = require('slugify');
const jwennTeksEpiId = async ids => { const jwennTeksEpiId = async ids => {
const paroles = await strapi.db.query('api::parole.parole').findMany({ const paroles = await strapi.db.query('api::parole.parole').findMany({
where: {id: {$in: ids}}, where: {id: {$in: ids}},
populate: ['artistes'] populate: ['artistes']
}) });
return paroles return paroles;
} };
const jwennAwtisEpiId = async id => { const jwennAwtisEpiId = async id => {
const artiste = await strapi.db.query('api::artiste.artiste').findOne({ const artiste = await strapi.db.query('api::artiste.artiste').findOne({
where: {id}, where: {id},
populate: ['paroles'] populate: ['paroles']
}) });
return artiste return artiste;
} };
const validateArtiste = alias => { const validateArtiste = alias => {
if (!alias || alias.trim().length === 0) { if (!alias || alias.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez choisir un alias.'); throw new ApplicationError('Champ obligatoire. Veuillez choisir un alias.');
} }
} };
module.exports = { module.exports = {
beforeUpdate: async event => { beforeUpdate: async event => {
let {data} = event.params let {data} = event.params;
if(!data.publishedAt) { if(!data.publishedAt) {
validateArtiste(data.alias) validateArtiste(data.alias);
if (!data.slug || data.slug !== slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g})) { if (!data.slug || data.slug !== slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g})) {
data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g}) data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g});
} }
} }
}, },
beforeCreate: async event => { beforeCreate: async event => {
let {data} = event.params let {data} = event.params;
validateArtiste(data.alias) validateArtiste(data.alias);
data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g}) data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g});
}, },
afterUpdate: async event => { afterUpdate: async event => {
const {result} = event const {result} = event;
const artiste = await jwennAwtisEpiId(result.id) const artiste = await jwennAwtisEpiId(result.id);
if (artiste.paroles && artiste.paroles.length >= 1) { if (artiste.paroles && artiste.paroles.length >= 1) {
const paroleIds = artiste.paroles.map(({id}) => id) const paroleIds = artiste.paroles.map(({id}) => id);
const paroles = await jwennTeksEpiId(paroleIds) const paroles = await jwennTeksEpiId(paroleIds);
await Promise.all(paroles.map(async t => { await Promise.all(paroles.map(async t => {
const {id, titre, slug, artistes} = t const {id, titre, slug, artistes} = t;
const alias = artistes.map(a => a.alias).join('-') const alias = artistes.map(a => a.alias).join('-');
const slugUpdated = slugify(`${alias}-${titre}`, {lower: true, remove: /[*#+~.()'"!:@]/g}) const slugUpdated = slugify(`${alias}-${titre}`, {lower: true, remove: /[*#+~.()'"!:@]/g});
if (slug !== slugUpdated) { if (slug !== slugUpdated) {
await strapi.db.query('api::parole.parole').update({ await strapi.db.query('api::parole.parole').update({
@@ -62,9 +62,9 @@ module.exports = {
data: { data: {
slug: slugUpdated slug: slugUpdated
} }
}) });
}
}))
} }
}));
} }
} }
};
@@ -1,17 +1,17 @@
import {describe, it, expect, vi} from 'vitest' import {describe, it, expect, vi} from 'vitest';
const {default: createController} = await import('../artiste.js') const {default: createController} = await import('../artiste.js');
function buildStrapi({dbUser, existingArtiste = null}) { function buildStrapi({dbUser, existingArtiste = null}) {
const dbQuery = { const dbQuery = {
findOne: vi.fn(async () => existingArtiste) findOne: vi.fn(async () => existingArtiste)
} };
const artisteDocuments = { const artisteDocuments = {
create: vi.fn(async ({data}) => ({id: 42, ...data})) create: vi.fn(async ({data}) => ({id: 42, ...data}))
} };
const userDocuments = { const userDocuments = {
findOne: vi.fn(async () => dbUser) findOne: vi.fn(async () => dbUser)
} };
const strapi = { const strapi = {
contentType: vi.fn(() => ({uid: 'api::artiste.artiste', kind: 'collectionType'})), contentType: vi.fn(() => ({uid: 'api::artiste.artiste', kind: 'collectionType'})),
@@ -19,13 +19,13 @@ function buildStrapi({dbUser, existingArtiste = null}) {
query: vi.fn(() => dbQuery) query: vi.fn(() => dbQuery)
}, },
documents: vi.fn(uid => { documents: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return userDocuments if (uid === 'plugin::users-permissions.user') return userDocuments;
if (uid === 'api::artiste.artiste') return artisteDocuments if (uid === 'api::artiste.artiste') return artisteDocuments;
throw new Error(`unexpected uid: ${uid}`) throw new Error(`unexpected uid: ${uid}`);
}) })
} };
return {strapi, artisteDocuments} return {strapi, artisteDocuments};
} }
function buildCtx(data) { function buildCtx(data) {
@@ -36,38 +36,56 @@ function buildCtx(data) {
}, },
badRequest: vi.fn(), badRequest: vi.fn(),
notFound: vi.fn() notFound: vi.fn()
} };
} }
const dbUser = {id: 1, documentId: 'user-doc-1', username: 'foo', email: 'foo@bar.com'} const dbUser = {id: 1, documentId: 'user-doc-1', username: 'foo', email: 'foo@bar.com'};
function buildData(overrides = {}) { function buildData(overrides = {}) {
return { return {
alias: 'Test Artist', alias: 'Test Artist',
user: {...dbUser}, user: {...dbUser},
...overrides ...overrides
} };
} }
describe('artiste.create', () => { describe('artiste.create', () => {
it('crée l\'artiste quand le user existe et que l\'alias est nouveau', async () => { it('crée l\'artiste quand le user existe et que l\'alias est nouveau', async () => {
const {strapi, artisteDocuments} = buildStrapi({dbUser}) const {strapi, artisteDocuments} = buildStrapi({dbUser});
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData()) const ctx = buildCtx(buildData());
await controller.create(ctx) await controller.create(ctx);
expect(artisteDocuments.create).toHaveBeenCalled() expect(artisteDocuments.create).toHaveBeenCalled();
}) });
it('refuse sans planter quand data.user est absent', async () => { it('refuse sans planter quand data.user est absent', async () => {
const {strapi} = buildStrapi({dbUser}) const {strapi} = buildStrapi({dbUser});
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData({user: undefined})) const ctx = buildCtx(buildData({user: undefined}));
await controller.create(ctx) await controller.create(ctx);
expect(ctx.badRequest).toHaveBeenCalled() expect(ctx.badRequest).toHaveBeenCalled();
expect(strapi.documents).not.toHaveBeenCalled() expect(strapi.documents).not.toHaveBeenCalled();
}) });
})
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, artisteDocuments} = buildStrapi({dbUser});
const controller = createController({strapi});
const ctx = buildCtx(buildData({
isExclusiveArtist: true,
userAdmin: {id: 999}
}));
await controller.create(ctx);
expect(artisteDocuments.create).toHaveBeenCalledWith({
data: {
alias: 'Test Artist',
user: dbUser.id
}
});
});
});
+16 -15
View File
@@ -1,47 +1,48 @@
'use strict'; 'use strict';
const { createCoreController } = require('@strapi/strapi').factories; const { createCoreController } = require('@strapi/strapi').factories;
const slugify = require('slugify') const slugify = require('slugify');
const getSlug = text => { const getSlug = text => {
return slugify(text, {lower: true, remove: /[*#+~.()'"!:@]/g}) return slugify(text, {lower: true, remove: /[*#+~.()'"!:@]/g});
} };
module.exports = createCoreController('api::artiste.artiste', ({strapi}) => ({ module.exports = createCoreController('api::artiste.artiste', ({strapi}) => ({
async create(ctx) { async create(ctx) {
const {body} = ctx.request const {body} = ctx.request;
let {data} = body let {data} = body;
if (!data?.user?.documentId) { if (!data?.user?.documentId) {
return ctx.badRequest('Informations manquantes.') return ctx.badRequest('Informations manquantes.');
} }
const user = await strapi.documents('plugin::users-permissions.user').findOne({ const user = await strapi.documents('plugin::users-permissions.user').findOne({
documentId: body.data.user.documentId documentId: body.data.user.documentId
}) });
if (!user) { if (!user) {
return ctx.notFound('Utilisateur introuvable.') return ctx.notFound('Utilisateur introuvable.');
} }
if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) { if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) {
return ctx.badRequest('Informations non valides.') return ctx.badRequest('Informations non valides.');
} }
const artiste = await strapi.db.query('api::artiste.artiste').findOne({ const artiste = await strapi.db.query('api::artiste.artiste').findOne({
where: {slug: getSlug(data.alias)} where: {slug: getSlug(data.alias)}
}) });
if (artiste) { if (artiste) {
return artiste return artiste;
} else { } else {
const newArtiste = await strapi.documents('api::artiste.artiste').create({ const newArtiste = await strapi.documents('api::artiste.artiste').create({
data: { data: {
...data alias: data.alias,
user: user.id
} }
}) });
return newArtiste return newArtiste;
} }
} }
})) }));
+1 -1
View File
@@ -14,4 +14,4 @@ module.exports = createCoreRouter('api::artiste.artiste', {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::artiste.artiste'}}] policies: [{name: 'global::is-document-owner', config: {uid: 'api::artiste.artiste'}}]
} }
} }
}) });
@@ -0,0 +1,59 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
async function loadLifecycles(strapiMock) {
vi.resetModules();
global.strapi = strapiMock;
const mod = await import('../lifecycles.js');
return mod;
}
describe('commentaire afterCreate — notification email', () => {
afterEach(() => {
delete global.strapi;
});
it('envoie le contenu en texte brut, jamais comme HTML non échappé', async () => {
const emailSend = vi.fn();
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return {findOne: vi.fn(async () => ({id: 1, username: 'foo'}))};
if (uid === 'api::parole.parole') return {findOne: vi.fn(async () => ({id: 7, titre: 'Mon titre'}))};
throw new Error(`unexpected uid: ${uid}`);
})
},
plugins: {email: {services: {email: {send: emailSend}}}}
};
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {user: {connect: [{id: 1}]}, parole: {connect: [{id: 7}]}, contenu: '<img src=x onerror=alert(1)>'}}});
expect(emailSend).toHaveBeenCalledTimes(1);
const [payload] = emailSend.mock.calls[0];
expect(payload.text).toBe('<img src=x onerror=alert(1)>');
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}});
});
});
@@ -1,57 +1,68 @@
'use strict'; 'use strict';
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;
} }
const user = await strapi.db.query('plugin::users-permissions.user').findOne({ const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: {id: userId} where: {id: userId}
}) });
return user return user;
} };
const jwennParoleEpiId = async paroleId => { const jwennParoleEpiId = async paroleId => {
if (!paroleId) { if (!paroleId) {
return null return null;
} }
const parole = await strapi.db.query('api::parole.parole').findOne({ const parole = await strapi.db.query('api::parole.parole').findOne({
where: {id: paroleId} where: {id: paroleId}
}) });
return parole return parole;
} };
const validateCommentaire = data => { const validateCommentaire = data => {
if (!data.contenu && !data.datePublication) { if (!data.contenu && !data.datePublication) {
throw new ApplicationError('Mauvaise requête, contenu et datePublication sont obligatoires') throw new ApplicationError('Mauvaise requête, contenu et datePublication sont obligatoires');
} }
if (!data.contenu || data.contenu.trim().length === 0) { if (!data.contenu || data.contenu.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez renseigner le contenu du commentaire.') throw new ApplicationError('Champ obligatoire. Veuillez renseigner le contenu du commentaire.');
} }
if (data.contenu.trim().length > 500) { if (data.contenu.trim().length > 500) {
throw new ApplicationError('Le commentaire doit contenir 500 caractères maximum.') throw new ApplicationError('Le commentaire doit contenir 500 caractères maximum.');
}
} }
};
module.exports = { module.exports = {
beforeCreate: async event => { beforeCreate: async event => {
const {data} = event.params const {data} = event.params;
validateCommentaire(data) validateCommentaire(data);
}, },
afterCreate: async event => { afterCreate: async event => {
const {data, result} = 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.');
} }
if (user) { if (user) {
@@ -59,8 +70,8 @@ module.exports = {
from: process.env.SMTP_FROM, from: process.env.SMTP_FROM,
to: process.env.SMTP_SEND_TO, to: process.env.SMTP_SEND_TO,
subject: `Commentaire de ${user.username} sur "${parole.titre}"`, subject: `Commentaire de ${user.username} sur "${parole.titre}"`,
html: data.contenu text: data.contenu
}) });
} }
} }
}; };
@@ -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"
} }
} }
} }
@@ -1,41 +1,41 @@
import {describe, it, expect, vi} from 'vitest' import {describe, it, expect, vi} from 'vitest';
const {default: createController} = await import('../commentaire.js') const {default: createController} = await import('../commentaire.js');
const dbUser = {id: 1, username: 'foo', email: 'foo@bar.com'} const dbUser = {id: 1, username: 'foo', email: 'foo@bar.com'};
const dbParole = {id: 7, documentId: 'parole-doc-7'} const dbParole = {id: 7, documentId: 'parole-doc-7'};
function buildStrapi({existingParole = dbParole} = {}) { function buildStrapi({existingParole = dbParole} = {}) {
const commentaireDocuments = { const commentaireDocuments = {
create: vi.fn(async ({data}) => ({id: 99, ...data})) create: vi.fn(async ({data}) => ({id: 99, ...data}))
} };
const paroleDocuments = { const paroleDocuments = {
update: vi.fn(async () => {}) update: vi.fn(async () => {})
} };
const userDbQuery = { const userDbQuery = {
findOne: vi.fn(async ({where}) => (where.id === dbUser.id ? dbUser : null)) findOne: vi.fn(async ({where}) => (where.id === dbUser.id ? dbUser : null))
} };
const paroleDbQuery = { const paroleDbQuery = {
findOne: vi.fn(async ({where}) => (where.id === existingParole?.id ? existingParole : null)) findOne: vi.fn(async ({where}) => (where.id === existingParole?.id ? existingParole : null))
} };
const strapi = { const strapi = {
contentType: vi.fn(() => ({uid: 'api::commentaire.commentaire', kind: 'collectionType'})), contentType: vi.fn(() => ({uid: 'api::commentaire.commentaire', kind: 'collectionType'})),
db: { db: {
query: vi.fn(uid => { query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return userDbQuery if (uid === 'plugin::users-permissions.user') return userDbQuery;
if (uid === 'api::parole.parole') return paroleDbQuery if (uid === 'api::parole.parole') return paroleDbQuery;
throw new Error(`unexpected uid: ${uid}`) throw new Error(`unexpected uid: ${uid}`);
}) })
}, },
documents: vi.fn(uid => { documents: vi.fn(uid => {
if (uid === 'api::commentaire.commentaire') return commentaireDocuments if (uid === 'api::commentaire.commentaire') return commentaireDocuments;
if (uid === 'api::parole.parole') return paroleDocuments if (uid === 'api::parole.parole') return paroleDocuments;
throw new Error(`unexpected uid: ${uid}`) throw new Error(`unexpected uid: ${uid}`);
}) })
} };
return {strapi, commentaireDocuments, paroleDocuments, userDbQuery, paroleDbQuery} return {strapi, commentaireDocuments, paroleDocuments, userDbQuery, paroleDbQuery};
} }
function buildCtx(data) { function buildCtx(data) {
@@ -44,7 +44,7 @@ function buildCtx(data) {
body: {data}, body: {data},
header: {authorization: 'Bearer faketoken'} header: {authorization: 'Bearer faketoken'}
} }
} };
} }
function buildData(overrides = {}) { function buildData(overrides = {}) {
@@ -54,49 +54,66 @@ function buildData(overrides = {}) {
parole: dbParole.id, parole: dbParole.id,
user: {...dbUser}, user: {...dbUser},
...overrides ...overrides
} };
} }
describe('commentaire.create', () => { describe('commentaire.create', () => {
it('retrouve la parole par son id (pas par le documentId du user) et l\'associe correctement', async () => { it('retrouve la parole par son id (pas par le documentId du user) et l\'associe correctement', async () => {
const {strapi, commentaireDocuments, paroleDocuments, paroleDbQuery} = buildStrapi() const {strapi, commentaireDocuments, paroleDocuments, paroleDbQuery} = buildStrapi();
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData()) const ctx = buildCtx(buildData());
await controller.create(ctx) await controller.create(ctx);
expect(paroleDbQuery.findOne).toHaveBeenCalledWith({where: {id: dbParole.id}}) expect(paroleDbQuery.findOne).toHaveBeenCalledWith({where: {id: dbParole.id}});
expect(commentaireDocuments.create).toHaveBeenCalled() expect(commentaireDocuments.create).toHaveBeenCalled();
expect(paroleDocuments.update).toHaveBeenCalledWith({ expect(paroleDocuments.update).toHaveBeenCalledWith({
documentId: dbParole.documentId, documentId: dbParole.documentId,
data: {commentaires: [99]} data: {commentaires: {connect: [99]}}
}) });
}) });
it('rejette quand la parole ciblée n\'existe pas', async () => { it('rejette quand la parole ciblée n\'existe pas', async () => {
const {strapi, commentaireDocuments} = buildStrapi({existingParole: null}) const {strapi, commentaireDocuments} = buildStrapi({existingParole: null});
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData()) const ctx = buildCtx(buildData());
await expect(controller.create(ctx)).rejects.toThrow('Texte introuvable.') await expect(controller.create(ctx)).rejects.toThrow('Texte introuvable.');
expect(commentaireDocuments.create).not.toHaveBeenCalled() expect(commentaireDocuments.create).not.toHaveBeenCalled();
}) });
it('refuse sans planter quand data.user est absent', async () => { it('refuse sans planter quand data.user est absent', async () => {
const {strapi, userDbQuery} = buildStrapi() const {strapi, userDbQuery} = buildStrapi();
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData({user: undefined})) const ctx = buildCtx(buildData({user: undefined}));
await expect(controller.create(ctx)).rejects.toThrow('Informations manquantes.') await expect(controller.create(ctx)).rejects.toThrow('Informations manquantes.');
expect(userDbQuery.findOne).not.toHaveBeenCalled() expect(userDbQuery.findOne).not.toHaveBeenCalled();
}) });
it('refuse sans planter quand data.parole est absent', async () => { it('refuse sans planter quand data.parole est absent', async () => {
const {strapi, paroleDbQuery} = buildStrapi() const {strapi, paroleDbQuery} = buildStrapi();
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData({parole: undefined})) const ctx = buildCtx(buildData({parole: undefined}));
await expect(controller.create(ctx)).rejects.toThrow('Informations manquantes.') await expect(controller.create(ctx)).rejects.toThrow('Informations manquantes.');
expect(paroleDbQuery.findOne).not.toHaveBeenCalled() expect(paroleDbQuery.findOne).not.toHaveBeenCalled();
}) });
})
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, commentaireDocuments} = buildStrapi();
const controller = createController({strapi});
const ctx = buildCtx(buildData({publishedAt: '2020-01-01'}));
await controller.create(ctx);
expect(commentaireDocuments.create).toHaveBeenCalledWith({
data: {
contenu: 'Un commentaire',
datePublication: '2026-07-04',
user: dbUser.id,
parole: dbParole.id
}
});
});
});
+17 -16
View File
@@ -1,53 +1,54 @@
'use strict'; 'use strict';
const { createCoreController } = require('@strapi/strapi').factories; const { createCoreController } = require('@strapi/strapi').factories;
const { ApplicationError, NotFoundError } = require("@strapi/utils").errors const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
module.exports = createCoreController('api::commentaire.commentaire', ({strapi}) => ({ module.exports = createCoreController('api::commentaire.commentaire', ({strapi}) => ({
async create(ctx) { async create(ctx) {
const {body} = ctx.request const {body} = ctx.request;
let {data} = body let {data} = body;
if (!data?.user?.id || !data?.parole) { if (!data?.user?.id || !data?.parole) {
throw new ApplicationError('Informations manquantes.') throw new ApplicationError('Informations manquantes.');
} }
const user = await strapi.db.query('plugin::users-permissions.user').findOne({ const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: {id: data.user.id} where: {id: data.user.id}
}) });
if (!user) { if (!user) {
throw new NotFoundError('Utilisateur introuvable.') throw new NotFoundError('Utilisateur introuvable.');
} }
if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) { if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) {
throw new ApplicationError('Informations non valides.') throw new ApplicationError('Informations non valides.');
} }
data.user = user.id
const parole = await strapi.db.query('api::parole.parole').findOne({ const parole = await strapi.db.query('api::parole.parole').findOne({
where: {id: data.parole} where: {id: data.parole}
}) });
if (!parole) { if (!parole) {
throw new NotFoundError('Texte introuvable.') throw new NotFoundError('Texte introuvable.');
} }
const newCommentaire = await strapi.documents('api::commentaire.commentaire').create({ const newCommentaire = await strapi.documents('api::commentaire.commentaire').create({
data: { data: {
...data contenu: data.contenu,
datePublication: data.datePublication,
user: user.id,
parole: parole.id
} }
}) });
await strapi.documents('api::parole.parole').update({ await strapi.documents('api::parole.parole').update({
documentId: parole.documentId, documentId: parole.documentId,
data: { data: {
commentaires: [newCommentaire.id] commentaires: {connect: [newCommentaire.id]}
} }
}) });
return newCommentaire; return newCommentaire;
} }
})) }));
+1 -1
View File
@@ -14,4 +14,4 @@ module.exports = createCoreRouter('api::commentaire.commentaire', {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::commentaire.commentaire'}}] policies: [{name: 'global::is-document-owner', config: {uid: 'api::commentaire.commentaire'}}]
} }
} }
}) });
@@ -1,61 +1,61 @@
import {describe, it, expect, vi, afterEach} from 'vitest' import {describe, it, expect, vi, afterEach} from 'vitest';
async function loadLifecycles(strapiMock) { async function loadLifecycles(strapiMock) {
vi.resetModules() vi.resetModules();
global.strapi = strapiMock global.strapi = strapiMock;
const mod = await import('../lifecycles.js') const mod = await import('../lifecycles.js');
return mod return mod;
} }
describe('afterCreate — notification au soumetteur', () => { describe('afterCreate — notification au soumetteur', () => {
afterEach(() => { afterEach(() => {
delete global.strapi delete global.strapi;
}) });
it('recherche le user par id (pas par un champ "user" inexistant) et envoie le mail', async () => { it('recherche le user par id (pas par un champ "user" inexistant) et envoie le mail', async () => {
const userFindOne = vi.fn(async ({where}) => { const userFindOne = vi.fn(async ({where}) => {
if (where.id === 5) return {id: 5, username: 'foo', email: 'foo@bar.com'} if (where.id === 5) return {id: 5, username: 'foo', email: 'foo@bar.com'};
return null return null;
}) });
const emailSend = vi.fn() const emailSend = vi.fn();
const strapiMock = { const strapiMock = {
db: { db: {
query: vi.fn(uid => { query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return {findOne: userFindOne} if (uid === 'plugin::users-permissions.user') return {findOne: userFindOne};
return {findOne: vi.fn(async () => null)} return {findOne: vi.fn(async () => null)};
}) })
}, },
plugins: { plugins: {
email: {services: {email: {send: emailSend}}} email: {services: {email: {send: emailSend}}}
} }
} };
const {afterCreate} = await loadLifecycles(strapiMock) const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {titre: 'Titre', user: {id: 5}}}}) await afterCreate({params: {data: {titre: 'Titre', user: {id: 5}}}});
expect(userFindOne).toHaveBeenCalledWith({where: {id: 5}}) expect(userFindOne).toHaveBeenCalledWith({where: {id: 5}});
expect(emailSend).toHaveBeenCalled() expect(emailSend).toHaveBeenCalled();
}) });
}) });
describe('beforeUpdate — notifications Telegram/Revolt', () => { describe('beforeUpdate — notifications Telegram/Revolt', () => {
const originalEnv = {...process.env} const originalEnv = {...process.env};
const axios = require('axios') const axios = require('axios');
const originalPost = axios.post const originalPost = axios.post;
function buildStrapi(previous) { function buildStrapi(previous) {
const dbQuery = { const dbQuery = {
findOne: vi.fn(async () => previous), findOne: vi.fn(async () => previous),
updateMany: vi.fn() updateMany: vi.fn()
} };
return { return {
db: {query: vi.fn(() => dbQuery)}, db: {query: vi.fn(() => dbQuery)},
plugins: {email: {services: {email: {send: vi.fn()}}}}, plugins: {email: {services: {email: {send: vi.fn()}}}},
log: {error: vi.fn()} log: {error: vi.fn()}
} };
} }
function buildEvent(overrides = {}) { function buildEvent(overrides = {}) {
@@ -64,79 +64,211 @@ describe('beforeUpdate — notifications Telegram/Revolt', () => {
params: { params: {
data: {documentId: 'doc-1', publishedAt: '2026-07-04T00:00:00.000Z', ...overrides} data: {documentId: 'doc-1', publishedAt: '2026-07-04T00:00:00.000Z', ...overrides}
} }
} };
} }
afterEach(() => { afterEach(() => {
process.env = {...originalEnv} process.env = {...originalEnv};
axios.post = originalPost axios.post = originalPost;
delete global.strapi delete global.strapi;
}) });
it("n'interrompt pas la publication quand Telegram échoue, et encode le message", async () => { it('n\'interrompt pas la publication quand Telegram échoue, et encode le message', async () => {
process.env.TELEGRAM_API_TOKEN = 'fake-token' process.env.TELEGRAM_API_TOKEN = 'fake-token';
process.env.TELEGRAM_CHAN_ID = 'fake-chan' process.env.TELEGRAM_CHAN_ID = 'fake-chan';
delete process.env.REVOLT_TOKEN delete process.env.REVOLT_TOKEN;
axios.post = vi.fn(async () => { axios.post = vi.fn(async () => {
throw new Error('boom') throw new Error('boom');
}) });
const previous = {publishedAt: null, slug: 'foo&bar', titre: 'Mon titre', user: null, userAdmin: null, artistes: []} const previous = {publishedAt: null, slug: 'foo&bar', titre: 'Mon titre', user: null, userAdmin: null, artistes: []};
const strapiMock = buildStrapi(previous) const strapiMock = buildStrapi(previous);
const {beforeUpdate} = await loadLifecycles(strapiMock) const {beforeUpdate} = await loadLifecycles(strapiMock);
await beforeUpdate(buildEvent()) await beforeUpdate(buildEvent());
expect(axios.post).toHaveBeenCalledTimes(1) expect(axios.post).toHaveBeenCalledTimes(1);
const [calledUrl] = axios.post.mock.calls[0] const [calledUrl] = axios.post.mock.calls[0];
expect(calledUrl).toContain('text=') expect(calledUrl).toContain('text=');
expect(calledUrl.split('text=')[1]).not.toContain('&bar') expect(calledUrl.split('text=')[1]).not.toContain('&bar');
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('Telegram')) expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('Telegram'));
}) });
it("n'interrompt pas la publication quand Revolt échoue", async () => { it('n\'interrompt pas la publication quand Revolt échoue', async () => {
delete process.env.TELEGRAM_API_TOKEN delete process.env.TELEGRAM_API_TOKEN;
process.env.REVOLT_TOKEN = 'fake-token' process.env.REVOLT_TOKEN = 'fake-token';
process.env.REVOLT_TARGET = 'fake-target' process.env.REVOLT_TARGET = 'fake-target';
process.env.REVOLT_BOT_ID = 'fake-bot' process.env.REVOLT_BOT_ID = 'fake-bot';
axios.post = vi.fn(async () => { axios.post = vi.fn(async () => {
throw new Error('boom') throw new Error('boom');
}) });
const previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: []} const previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: []};
const strapiMock = buildStrapi(previous) const strapiMock = buildStrapi(previous);
const {beforeUpdate} = await loadLifecycles(strapiMock) const {beforeUpdate} = await loadLifecycles(strapiMock);
await beforeUpdate(buildEvent()) await beforeUpdate(buildEvent());
expect(axios.post).toHaveBeenCalledTimes(1) expect(axios.post).toHaveBeenCalledTimes(1);
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('Revolt')) expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('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', () => {
afterEach(() => {
delete global.strapi;
});
it('retire createdBy/updatedBy du payload avant la mise à jour', async () => {
const dbQuery = {findOne: vi.fn(async () => ({publishedAt: null, artistes: []}))};
const strapiMock = {db: {query: vi.fn(() => dbQuery)}};
const {beforeUpdate} = await loadLifecycles(strapiMock);
const event = {
state: {},
params: {
data: {documentId: 'doc-1', createdBy: 999, updatedBy: 999}
}
};
await beforeUpdate(event);
expect(event.params.data.createdBy).toBeUndefined();
expect(event.params.data.updatedBy).toBeUndefined();
});
});
describe('afterUpdate — historique de différence', () => { describe('afterUpdate — historique de différence', () => {
afterEach(() => { afterEach(() => {
delete global.strapi delete global.strapi;
}) });
it("n'échoue pas quand updatedBy n'est pas peuplé", async () => { it('n\'échoue pas quand updatedBy n\'est pas peuplé', async () => {
const entityServiceUpdate = vi.fn(async () => {}) const entityServiceUpdate = vi.fn(async () => {});
const strapiMock = { const strapiMock = {
entityService: {update: entityServiceUpdate} entityService: {update: entityServiceUpdate}
} };
const {afterUpdate} = await loadLifecycles(strapiMock) const {afterUpdate} = await loadLifecycles(strapiMock);
const event = { const event = {
result: {id: 1, difference: [], updatedBy: undefined}, result: {id: 1, difference: [], updatedBy: undefined},
state: {diff: {path: 'transcription', jsonDiff: []}} state: {diff: {path: 'transcription', jsonDiff: []}}
} };
await afterUpdate(event) await afterUpdate(event);
expect(entityServiceUpdate).toHaveBeenCalledWith('api::parole.parole', 1, { expect(entityServiceUpdate).toHaveBeenCalledWith('api::parole.parole', 1, {
data: { data: {
@@ -148,6 +280,6 @@ describe('afterUpdate — historique de différence', () => {
sources: 'transcription' sources: 'transcription'
}] }]
} }
}) });
}) });
}) });
+110 -84
View File
@@ -1,22 +1,23 @@
'use strict'; 'use strict';
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;
const TELEGRAM_API_URL = 'https://api.telegram.org' const TELEGRAM_API_URL = 'https://api.telegram.org';
const TELEGRAM_CHAN_ID = process.env.TELEGRAM_CHAN_ID || null const TELEGRAM_CHAN_ID = process.env.TELEGRAM_CHAN_ID || null;
const TELEGRAM_API_TOKEN = process.env.TELEGRAM_API_TOKEN || null const TELEGRAM_API_TOKEN = process.env.TELEGRAM_API_TOKEN || null;
const MESSAGE_URL = `${TELEGRAM_API_URL}/bot${TELEGRAM_API_TOKEN}/sendMessage?chat_id=${TELEGRAM_CHAN_ID}&parse_mode=html` const MESSAGE_URL = `${TELEGRAM_API_URL}/bot${TELEGRAM_API_TOKEN}/sendMessage?chat_id=${TELEGRAM_CHAN_ID}&parse_mode=html`;
const REVOLT_BOT_ID = process.env.REVOLT_BOT_ID || null const REVOLT_BOT_ID = process.env.REVOLT_BOT_ID || null;
const REVOLT_TARGET = process.env.REVOLT_TARGET || null const REVOLT_TARGET = process.env.REVOLT_TARGET || null;
const REVOLT_TOKEN = process.env.REVOLT_TOKEN || null const REVOLT_TOKEN = process.env.REVOLT_TOKEN || null;
const getSlug = (artiste, parole) => { const getSlug = (artiste, parole) => {
return slugify(`${artiste}-${parole}`, {lower: true, remove: /[*#+~.()'"!:@]/g}) return slugify(`${artiste}-${parole}`, {lower: true, remove: /[*#+~.()'"!:@]/g});
} };
const isSlugExists = async existingSlug => { const isSlugExists = async existingSlug => {
const slugs = await strapi.db.query('api::parole.parole').count({ const slugs = await strapi.db.query('api::parole.parole').count({
@@ -25,10 +26,10 @@ const isSlugExists = async existingSlug => {
$eq: existingSlug $eq: existingSlug
} }
} }
}) });
return Boolean(slugs) return Boolean(slugs);
} };
const jwennAwtisEpiId = async artistesIds => { const jwennAwtisEpiId = async artistesIds => {
if (!artistesIds || artistesIds.length === 0) { if (!artistesIds || artistesIds.length === 0) {
@@ -42,30 +43,30 @@ const jwennAwtisEpiId = async artistesIds => {
$in: artistesIds.map(id => id) $in: artistesIds.map(id => id)
} }
} }
}) });
return artistes.map(a => a.alias).join('-') return artistes.map(a => a.alias).join('-');
} };
const jwennUserEpiId = async userId => { const jwennUserEpiId = async userId => {
if (!userId) { if (!userId) {
return null return null;
} }
const user = await strapi.db.query('plugin::users-permissions.user').findOne({ const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: {id: userId} where: {id: userId}
}) });
if (!user) { if (!user) {
throw new ApplicationError('Utilisateur introuvable.') throw new ApplicationError('Utilisateur introuvable.');
} }
return user return user;
} };
const jwennUserAdminEpiId = async userAdminId => { const jwennUserAdminEpiId = async userAdminId => {
if (!userAdminId) { if (!userAdminId) {
return null return null;
} }
const userAdmin = await strapi.db.query('admin::user').findOne({ const userAdmin = await strapi.db.query('admin::user').findOne({
@@ -83,14 +84,14 @@ const jwennUserAdminEpiId = async userAdminId => {
} }
] ]
} }
}) });
return userAdmin return userAdmin;
} };
const jwennSuperAdminEpiId = async userAdminId => { const jwennSuperAdminEpiId = async userAdminId => {
if (!userAdminId) { if (!userAdminId) {
return null return null;
} }
const userAdmin = await strapi.db.query('admin::user').findOne({ const userAdmin = await strapi.db.query('admin::user').findOne({
@@ -108,87 +109,91 @@ const jwennSuperAdminEpiId = async userAdminId => {
} }
] ]
} }
}) });
return userAdmin return userAdmin;
} };
module.exports = { module.exports = {
beforeCreate: async event => { beforeCreate: async event => {
let {data} = event.params let {data} = event.params;
delete data.createdBy delete data.createdBy;
delete data.updatedBy delete data.updatedBy;
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription) strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
let artistesIds = [] let artistesIds = [];
if (data?.artistes?.connect?.length) { if (data?.artistes?.connect?.length) {
artistesIds = data.artistes.connect.map(a => a.id) artistesIds = data.artistes.connect.map(a => a.id);
if (data.titre && !data.forceSlug) { if (data.titre && !data.forceSlug) {
const artiste = await jwennAwtisEpiId(artistesIds) const artiste = await jwennAwtisEpiId(artistesIds);
data.slug = getSlug(artiste, data.titre) data.slug = getSlug(artiste, data.titre);
} }
const getSlugExistance = await isSlugExists(data.slug) const getSlugExistance = await isSlugExists(data.slug);
if (getSlugExistance) { if (getSlugExistance) {
throw new ApplicationError('Un morceau du même artiste existe déjà.') throw new ApplicationError('Un morceau du même artiste existe déjà.');
} }
} }
}, },
beforeUpdate: async event => { beforeUpdate: async event => {
const {state} = event const {state} = event;
let {data} = event.params let {data} = event.params;
const {documentId} = data
delete data.createdBy;
delete data.updatedBy;
const {documentId} = data;
if (data.isNewRelease === true) { if (data.isNewRelease === true) {
await strapi.db.query('api::parole.parole').updateMany({ await strapi.db.query('api::parole.parole').updateMany({
where: { isNewRelease: true }, where: { isNewRelease: true },
data: { isNewRelease: false }, data: { isNewRelease: false },
}) });
} }
const previousParoles = await strapi.db.query('api::parole.parole').findOne({ const previousParoles = await strapi.db.query('api::parole.parole').findOne({
where: {documentId}, where: {documentId},
populate: {difference: true, artistes: true} populate: {difference: true, artistes: true}
}) });
if (data.transcription && previousParoles.publishedAt) { if (data.transcription && previousParoles.publishedAt) {
const difference = strapi.service('api::parole.parole').parolesDiff(data.titre, previousParoles.transcription, data.transcription) const difference = strapi.service('api::parole.parole').parolesDiff(data.titre, previousParoles.transcription, data.transcription);
state.diff = difference state.diff = difference;
} }
if(!data.publishedAt && data.titre && data.transcription) { if(!data.publishedAt && data.titre && data.transcription) {
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription) strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
if (data.titre && !data.forceSlug) { if (data.titre && !data.forceSlug) {
let artistes let artistes;
if (data.artistes.connect.length === 0) { if (data.artistes.connect.length === 0) {
artistes = previousParoles.artistes.map(a => a.alias).join('-') artistes = previousParoles.artistes.map(a => a.alias).join('-');
} else { } else {
let artistesIds = [] let artistesIds = [];
artistesIds = data.artistes.connect.map(a => a.id) artistesIds = data.artistes.connect.map(a => a.id);
artistes = await jwennAwtisEpiId(artistesIds) artistes = await jwennAwtisEpiId(artistesIds);
} }
data.slug = getSlug(artistes, data.titre) data.slug = getSlug(artistes, data.titre);
} }
} }
if (data.publishedAt != null) { if (data.publishedAt != null) {
const previousData = await strapi.db.query('api::parole.parole').findOne({ const previousData = await strapi.db.query('api::parole.parole').findOne({
where: {documentId} where: {documentId}
}) });
const previousPublishedAt = previousData.publishedAt const previousPublishedAt = previousData.publishedAt;
const currentPublished_at = data.publishedAt const currentPublished_at = data.publishedAt;
if (currentPublished_at != previousPublishedAt) { if (currentPublished_at != previousPublishedAt) {
const message = `<b>Nouvelle publication</b> ❤️ const message = `<b>Nouvelle publication</b> ❤️
\n${process.env.WEBSITE_URL}/paroles/${previousData.slug}` \n${process.env.WEBSITE_URL}/paroles/${previousData.slug}`;
if (previousData.user) { if (previousData.user) {
strapi.plugins['email'].services.email.send({ strapi.plugins['email'].services.email.send({
from: process.env.SMTP_FROM, from: process.env.SMTP_FROM,
@@ -199,7 +204,7 @@ module.exports = {
Merci pour votre contribution ❤️`, Merci pour votre contribution ❤️`,
html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p> html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p>
<p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>` <p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>`
}) });
} }
if (previousData.userAdmin) { if (previousData.userAdmin) {
@@ -212,43 +217,43 @@ module.exports = {
Merci pour votre contribution ❤️`, Merci pour votre contribution ❤️`,
html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p> html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p>
<p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>` <p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>`
}) });
} }
if (TELEGRAM_API_TOKEN) { if (TELEGRAM_API_TOKEN) {
try { try {
await axios.post(`${MESSAGE_URL}&text=${encodeURIComponent(message)}`) await axios.post(`${MESSAGE_URL}&text=${encodeURIComponent(message)}`);
} catch (err) { } catch (err) {
strapi.log.error(`Notification Telegram : ${err.message}`) strapi.log.error(`Notification Telegram : ${err.message}`);
} }
} }
if (REVOLT_TOKEN && REVOLT_TARGET && REVOLT_BOT_ID) { if (REVOLT_TOKEN && REVOLT_TARGET && REVOLT_BOT_ID) {
const revoltMessage = `Nouvelle publication const revoltMessage = `Nouvelle publication
\n${process.env.WEBSITE_URL}/paroles/${previousData.slug}` \n${process.env.WEBSITE_URL}/paroles/${previousData.slug}`;
const targetChannel = REVOLT_TARGET const targetChannel = REVOLT_TARGET;
const botToken = REVOLT_TOKEN const botToken = REVOLT_TOKEN;
const url = `https://api.revolt.chat/channels/${targetChannel}/messages` const url = `https://api.revolt.chat/channels/${targetChannel}/messages`;
const config = { const config = {
headers: { headers: {
'X-Bot-Token': botToken, 'X-Bot-Token': botToken,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} };
try { try {
await axios.post(url, {content: revoltMessage}, config) await axios.post(url, {content: revoltMessage}, config);
} catch (err) { } catch (err) {
strapi.log.error(`Notification Revolt : ${err.message}`) strapi.log.error(`Notification Revolt : ${err.message}`);
} }
} }
} }
} }
}, },
afterUpdate: async event => { afterUpdate: async event => {
const {result, state} = event const {result, state} = event;
if (state.diff) { if (state.diff) {
await strapi.entityService.update('api::parole.parole', result.id, { await strapi.entityService.update('api::parole.parole', result.id, {
@@ -263,15 +268,36 @@ module.exports = {
sources: 'transcription' sources: 'transcription'
}] }]
} }
}) });
} }
}, },
afterCreate: async event => { afterCreate: async event => {
const {data} = event.params const {data} = event.params;
const user = await jwennUserEpiId(data?.user?.id)
const userAdmin = await jwennUserAdminEpiId(data?.createdBy) // Avec draftAndPublish, "publier" crée une nouvelle ligne (la version publiée)
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy) // au lieu de mettre à jour la ligne existante : c'est ici, et non dans
const traductionsId = data?.traductions?.id // 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 userAdmin = await jwennUserAdminEpiId(data?.createdBy);
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
const traductionsId = data?.traductions?.id;
if (traductionsId) { if (traductionsId) {
const result = await strapi.db.query('api::parole.parole').findOne({ const result = await strapi.db.query('api::parole.parole').findOne({
@@ -283,15 +309,15 @@ module.exports = {
} }
}, },
populate: {traductions: true, artistes: true} populate: {traductions: true, artistes: true}
}) });
if (superAdmin && data.traductionAuto && result.traductions.francais && (!result.traductions.anglais || !result.traductions.espagnol || !result.traductions.allemand || !result.traductions.italien)) { if (superAdmin && data.traductionAuto && result.traductions.francais && (!result.traductions.anglais || !result.traductions.espagnol || !result.traductions.allemand || !result.traductions.italien)) {
const traductions = await strapi.service('api::parole.parole').translateLyrics(result.traductions.francais) const traductions = await strapi.service('api::parole.parole').translateLyrics(result.traductions.francais);
await strapi.entityService.update('api::parole.parole', result.id, { await strapi.entityService.update('api::parole.parole', result.id, {
data: { data: {
traductions traductions
} }
}) });
} }
} }
@@ -302,7 +328,7 @@ module.exports = {
subject: `Nouveau texte de ${user.username} : "${data.titre}" (site)`, subject: `Nouveau texte de ${user.username} : "${data.titre}" (site)`,
text: `Le titre "${data.titre}" a été soumis depuis le site.`, text: `Le titre "${data.titre}" a été soumis depuis le site.`,
html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.` html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.`
}) });
} }
if (userAdmin) { if (userAdmin) {
@@ -312,7 +338,7 @@ module.exports = {
subject: `Nouveau texte de ${userAdmin.firstname} : "${data.titre}" (site)`, subject: `Nouveau texte de ${userAdmin.firstname} : "${data.titre}" (site)`,
text: `Le titre "${data.titre}" a été soumis depuis le site.`, text: `Le titre "${data.titre}" a été soumis depuis le site.`,
html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.` html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.`
}) });
}
} }
} }
};
@@ -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"
} }
} }
} }
@@ -1,19 +1,20 @@
import {describe, it, expect, vi} from 'vitest' import {describe, it, expect, vi} from 'vitest';
const {default: createController} = await import('../parole.js') const {default: createController} = await import('../parole.js');
function buildStrapi({dbUser, artiste}) { function buildStrapi({dbUser, artiste}) {
const paroleDocuments = { const paroleDocuments = {
findMany: vi.fn(async () => []), findMany: vi.fn(async () => []),
create: vi.fn(async ({data}) => ({id: 42, ...data})) create: vi.fn(async ({data}) => ({id: 42, ...data})),
} update: vi.fn(async ({data}) => ({id: 42, ...data}))
};
const userDocuments = { const userDocuments = {
findOne: vi.fn(async () => dbUser), findOne: vi.fn(async () => dbUser),
update: vi.fn(async () => {}) update: vi.fn(async () => {})
} };
const artisteDocuments = { const artisteDocuments = {
findOne: vi.fn(async () => artiste) findOne: vi.fn(async () => artiste)
} };
const strapi = { const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})), contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
@@ -22,14 +23,14 @@ function buildStrapi({dbUser, artiste}) {
translateLyrics: vi.fn() translateLyrics: vi.fn()
})), })),
documents: vi.fn(uid => { documents: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return userDocuments if (uid === 'plugin::users-permissions.user') return userDocuments;
if (uid === 'api::artiste.artiste') return artisteDocuments if (uid === 'api::artiste.artiste') return artisteDocuments;
if (uid === 'api::parole.parole') return paroleDocuments if (uid === 'api::parole.parole') return paroleDocuments;
throw new Error(`unexpected uid: ${uid}`) throw new Error(`unexpected uid: ${uid}`);
}) })
} };
return {strapi, paroleDocuments, userDocuments, artisteDocuments} return {strapi, paroleDocuments, userDocuments, artisteDocuments};
} }
function buildCtx(data) { function buildCtx(data) {
@@ -40,11 +41,11 @@ function buildCtx(data) {
}, },
badRequest: vi.fn(), badRequest: vi.fn(),
notFound: vi.fn() notFound: vi.fn()
} };
} }
const dbUser = {id: 1, documentId: 'user-doc-1', username: 'foo', email: 'foo@bar.com'} const dbUser = {id: 1, documentId: 'user-doc-1', username: 'foo', email: 'foo@bar.com'};
const artiste = {documentId: 'artiste-doc-1'} const artiste = {id: 9, documentId: 'artiste-doc-1'};
function buildData(overrides = {}) { function buildData(overrides = {}) {
return { return {
@@ -53,39 +54,117 @@ function buildData(overrides = {}) {
user: {...dbUser}, user: {...dbUser},
artistes: [{documentId: 'artiste-doc-1'}], artistes: [{documentId: 'artiste-doc-1'}],
...overrides ...overrides
};
} }
}
describe('parole.findOne', () => {
it('interroge avec le documentId venant de ctx.params.id, pas avec ctx lui-même', async () => {
const paroleDocuments = {
findOne: vi.fn(async ({documentId}) => ({id: 1, documentId, titre: 'Test'}))
};
const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
documents: vi.fn(uid => {
if (uid === 'api::parole.parole') return paroleDocuments;
throw new Error(`unexpected uid: ${uid}`);
})
};
const controller = createController({strapi});
const ctx = {params: {id: 'doc-123'}};
const result = await controller.findOne(ctx);
expect(paroleDocuments.findOne).toHaveBeenCalledWith({
documentId: 'doc-123',
populate: ['artistes']
});
expect(result).toEqual({id: 1, documentId: 'doc-123', titre: 'Test'});
});
});
describe('parole.create', () => { describe('parole.create', () => {
it('crée la parole quand le user et l\'artiste existent', async () => { it('crée la parole quand le user et l\'artiste existent', async () => {
const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste}) const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData()) const ctx = buildCtx(buildData());
await controller.create(ctx) await controller.create(ctx);
expect(paroleDocuments.create).toHaveBeenCalled() expect(paroleDocuments.create).toHaveBeenCalled();
}) });
it('refuse sans planter quand data.user est absent', async () => { it('refuse sans planter quand data.user est absent', async () => {
const {strapi, userDocuments} = buildStrapi({dbUser, artiste}) const {strapi, userDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData({user: undefined})) const ctx = buildCtx(buildData({user: undefined}));
await controller.create(ctx) await controller.create(ctx);
expect(ctx.badRequest).toHaveBeenCalled() expect(ctx.badRequest).toHaveBeenCalled();
expect(userDocuments.findOne).not.toHaveBeenCalled() expect(userDocuments.findOne).not.toHaveBeenCalled();
}) });
it('refuse sans planter quand data.artistes est vide', async () => { it('refuse sans planter quand data.artistes est vide', async () => {
const {strapi, artisteDocuments} = buildStrapi({dbUser, artiste}) const {strapi, artisteDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi}) const controller = createController({strapi});
const ctx = buildCtx(buildData({artistes: []})) const ctx = buildCtx(buildData({artistes: []}));
await controller.create(ctx) await controller.create(ctx);
expect(ctx.badRequest).toHaveBeenCalled() expect(ctx.badRequest).toHaveBeenCalled();
expect(artisteDocuments.findOne).not.toHaveBeenCalled() expect(artisteDocuments.findOne).not.toHaveBeenCalled();
}) });
})
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx(buildData({
userAdmin: {id: 999},
isNewRelease: true,
difference: [{fake: true}]
}));
await controller.create(ctx);
expect(paroleDocuments.create).toHaveBeenCalledWith({
data: {
titre: 'Test',
transcription: 'Paroles...',
traductions: undefined,
traductionAuto: undefined,
artistes: [artiste.id],
user: dbUser.id
}
});
});
});
describe('parole.update', () => {
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx({
documentId: 'doc-1',
titre: 'Nouveau titre',
transcription: 'Nouveau texte',
traductions: {francais: 'salut'},
traductionAuto: true,
artistes: [9],
userAdmin: {id: 999},
user: {id: 999}
});
await controller.update(ctx);
expect(paroleDocuments.update).toHaveBeenCalledWith({
documentId: 'doc-1',
data: {
titre: 'Nouveau titre',
transcription: 'Nouveau texte',
traductions: {francais: 'salut'},
traductionAuto: true,
artistes: [9]
}
});
});
});
+51 -41
View File
@@ -2,29 +2,29 @@
const { createCoreController } = require('@strapi/strapi').factories; const { createCoreController } = require('@strapi/strapi').factories;
const VALID_LANGS = new Set(['fr', 'en', 'es', 'de', 'it']) const VALID_LANGS = new Set(['fr', 'en', 'es', 'de', 'it']);
module.exports = createCoreController('api::parole.parole', ({strapi}) => ({ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
async export(ctx) { async export(ctx) {
const { type = 'pairs', lang, format = 'jsonl' } = ctx.query const { type = 'pairs', lang, format = 'jsonl' } = ctx.query;
const langs = lang const langs = lang
? lang.split(',').map(l => l.trim()).filter(l => VALID_LANGS.has(l)) ? lang.split(',').map(l => l.trim()).filter(l => VALID_LANGS.has(l))
: null : null;
if (lang && (!langs || langs.length === 0)) { if (lang && (!langs || langs.length === 0)) {
return ctx.badRequest('Langue(s) invalide(s). Valeurs acceptées : fr, en, es, de, it.') return ctx.badRequest('Langue(s) invalide(s). Valeurs acceptées : fr, en, es, de, it.');
} }
if (!['pairs', 'instruct'].includes(type)) { if (!['pairs', 'instruct'].includes(type)) {
return ctx.badRequest('type invalide. Valeurs acceptées : pairs, instruct.') return ctx.badRequest('type invalide. Valeurs acceptées : pairs, instruct.');
} }
const paroles = await strapi.service('api::parole.parole').fetchAllParoles() const paroles = await strapi.service('api::parole.parole').fetchAllParoles();
const { metadata, pairs } = strapi.service('api::parole.parole').buildExport(paroles, type, langs) const { metadata, pairs } = strapi.service('api::parole.parole').buildExport(paroles, type, langs);
if (format === 'json') { if (format === 'json') {
return ctx.send({ metadata, data: pairs }) return ctx.send({ metadata, data: pairs });
} }
// JSONL : première ligne = métadonnées, suivies des exemples d'entraînement. // JSONL : première ligne = métadonnées, suivies des exemples d'entraînement.
@@ -32,68 +32,73 @@ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
const lines = [ const lines = [
JSON.stringify({ _metadata: true, ...metadata }), JSON.stringify({ _metadata: true, ...metadata }),
...pairs.map(p => JSON.stringify(p)), ...pairs.map(p => JSON.stringify(p)),
] ];
ctx.set('Content-Type', 'application/x-ndjson') ctx.set('Content-Type', 'application/x-ndjson');
ctx.set('Content-Disposition', `attachment; filename="pawol-nu-export-${Date.now()}.jsonl"`) ctx.set('Content-Disposition', `attachment; filename="pawol-nu-export-${Date.now()}.jsonl"`);
ctx.body = lines.join('\n') ctx.body = lines.join('\n');
}, },
async bulkTranslate(ctx) { async bulkTranslate(ctx) {
const result = await strapi.service('api::parole.parole').bulkTranslateMissing() const result = await strapi.service('api::parole.parole').bulkTranslateMissing();
return ctx.send(result) return ctx.send(result);
}, },
async findOne(documentId) { async findOne(ctx) {
const {id: documentId} = ctx.params;
const parole = await strapi.documents('api::parole.parole').findOne({ const parole = await strapi.documents('api::parole.parole').findOne({
documentId, documentId,
populate: ['artistes'] populate: ['artistes']
}) });
return parole return parole;
}, },
async update(ctx) { async update(ctx) {
const {body} = ctx.request const {body} = ctx.request;
const {data} = body const {data} = body;
const updatedParole = await strapi.documents('api::parole.parole').update({ const updatedParole = await strapi.documents('api::parole.parole').update({
documentId: data.documentId, documentId: data.documentId,
data: { data: {
...data titre: data.titre,
transcription: data.transcription,
traductions: data.traductions,
traductionAuto: data.traductionAuto,
artistes: data.artistes
} }
}) });
return updatedParole return updatedParole;
}, },
async create(ctx) { async create(ctx) {
const {body} = ctx.request const {body} = ctx.request;
const {data} = body const {data} = body;
if (!data?.user?.documentId || !data?.artistes?.[0]?.documentId) { if (!data?.user?.documentId || !data?.artistes?.[0]?.documentId) {
return ctx.badRequest('Informations manquantes.') return ctx.badRequest('Informations manquantes.');
} }
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription) strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
const user = await strapi.documents('plugin::users-permissions.user').findOne({ const user = await strapi.documents('plugin::users-permissions.user').findOne({
documentId: body.data.user.documentId documentId: body.data.user.documentId
}) });
if (!user) { if (!user) {
return ctx.notFound('Utilisateur introuvable.') return ctx.notFound('Utilisateur introuvable.');
} }
if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) { if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) {
return ctx.badRequest('Informations non valides.') return ctx.badRequest('Informations non valides.');
} }
const artiste = await strapi.documents('api::artiste.artiste').findOne({ const artiste = await strapi.documents('api::artiste.artiste').findOne({
documentId: data.artistes[0].documentId documentId: data.artistes[0].documentId
}) });
if (!artiste) { if (!artiste) {
return ctx.notFound('Artiste introuvable.') return ctx.notFound('Artiste introuvable.');
} }
const currentUserParole = await strapi.documents('api::parole.parole').findMany({ const currentUserParole = await strapi.documents('api::parole.parole').findMany({
@@ -108,22 +113,27 @@ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
$eq: null $eq: null
} }
} }
}) });
if (user && user.canAutoTranslate && data.traductionAuto && data.traductions.francais && (!data.traductions.anglais || !data.traductions.espagnol || !data.traductions.allemand || !data.traductions.italien)) { if (user && user.canAutoTranslate && data.traductionAuto && data.traductions.francais && (!data.traductions.anglais || !data.traductions.espagnol || !data.traductions.allemand || !data.traductions.italien)) {
const translated = await strapi.service('api::parole.parole').translateLyrics(data.traductions.francais) const translated = await strapi.service('api::parole.parole').translateLyrics(data.traductions.francais);
data.traductions = translated data.traductions = translated;
} }
const newParole = await strapi.documents('api::parole.parole').create({ const newParole = await strapi.documents('api::parole.parole').create({
data: { data: {
...data titre: data.titre,
transcription: data.transcription,
traductions: data.traductions,
traductionAuto: data.traductionAuto,
artistes: [artiste.id],
user: user.id
} }
}) });
const parolesIds = currentUserParole.map(({id}) => id) const parolesIds = currentUserParole.map(({id}) => id);
parolesIds.push(newParole.id) parolesIds.push(newParole.id);
await strapi.documents('plugin::users-permissions.user').update({ await strapi.documents('plugin::users-permissions.user').update({
documentId: user.documentId, documentId: user.documentId,
@@ -131,8 +141,8 @@ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
data: { data: {
paroles: parolesIds paroles: parolesIds
} }
}) });
return newParole return newParole;
} }
})) }));
@@ -1,19 +1,19 @@
import {describe, it, expect} from 'vitest' import {describe, it, expect} from 'vitest';
import isApiToken from '../is-api-token.js' import isApiToken from '../is-api-token.js';
describe('is-api-token policy', () => { describe('is-api-token policy', () => {
it('autorise une requête authentifiée par token API', () => { it('autorise une requête authentifiée par token API', () => {
const ctx = {state: {auth: {strategy: {name: 'api-token'}}}} const ctx = {state: {auth: {strategy: {name: 'api-token'}}}};
expect(isApiToken(ctx)).toBe(true) expect(isApiToken(ctx)).toBe(true);
}) });
it('refuse une requête authentifiée autrement (ex: JWT utilisateur)', () => { it('refuse une requête authentifiée autrement (ex: JWT utilisateur)', () => {
const ctx = {state: {auth: {strategy: {name: 'users-permissions'}}}} const ctx = {state: {auth: {strategy: {name: 'users-permissions'}}}};
expect(isApiToken(ctx)).toBe(false) expect(isApiToken(ctx)).toBe(false);
}) });
it('refuse une requête non authentifiée', () => { it('refuse une requête non authentifiée', () => {
const ctx = {state: {}} const ctx = {state: {}};
expect(isApiToken(ctx)).toBe(false) expect(isApiToken(ctx)).toBe(false);
}) });
}) });
+2 -2
View File
@@ -1,5 +1,5 @@
'use strict'; 'use strict';
module.exports = policyContext => { module.exports = policyContext => {
return policyContext.state?.auth?.strategy?.name === 'api-token' return policyContext.state?.auth?.strategy?.name === 'api-token';
} };
+1 -1
View File
@@ -14,4 +14,4 @@ module.exports = createCoreRouter('api::parole.parole', {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::parole.parole'}}] policies: [{name: 'global::is-document-owner', config: {uid: 'api::parole.parole'}}]
} }
} }
}) });
@@ -1,60 +1,84 @@
import {describe, it, expect, vi, afterEach} from 'vitest' import {describe, it, expect, vi, afterEach} from 'vitest';
const {default: createService} = await import('../parole.js') const {default: createService} = await import('../parole.js');
function fakeDeeplResponse(text) { function fakeDeeplResponse(text) {
return { return {
ok: true, ok: true,
json: async () => ({translations: [{text}]}) json: async () => ({translations: [{text}]})
} };
} }
describe('Translator (DeepL)', () => { describe('Translator (DeepL)', () => {
const originalFetch = global.fetch const originalFetch = global.fetch;
afterEach(() => { afterEach(() => {
global.fetch = originalFetch global.fetch = originalFetch;
vi.restoreAllMocks() vi.restoreAllMocks();
}) });
it('attache un timeout à la requête DeepL', async () => { it('attache un timeout à la requête DeepL', async () => {
global.fetch = vi.fn(async () => fakeDeeplResponse('hello')) global.fetch = vi.fn(async () => fakeDeeplResponse('hello'));
const strapi = {contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'}))} const strapi = {contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'}))};
const service = createService({strapi}) const service = createService({strapi});
await service.translate('FR', 'EN', 'bonjour') await service.translate('FR', 'EN', 'bonjour');
const [, options] = global.fetch.mock.calls[0] const [, options] = global.fetch.mock.calls[0];
expect(options.signal).toBeInstanceOf(AbortSignal) expect(options.signal).toBeInstanceOf(AbortSignal);
}) });
}) });
describe('translateLyrics', () => { describe('translateLyrics', () => {
const originalFetch = global.fetch const originalFetch = global.fetch;
afterEach(() => { afterEach(() => {
global.fetch = originalFetch global.fetch = originalFetch;
vi.restoreAllMocks() vi.restoreAllMocks();
}) });
it('continue les autres langues quand une traduction DeepL échoue', async () => { it('continue les autres langues quand une traduction DeepL échoue', async () => {
global.fetch = vi.fn(async (_url, options) => { global.fetch = vi.fn(async (_url, options) => {
const {target_lang: target} = JSON.parse(options.body) const {target_lang: target} = JSON.parse(options.body);
if (target === 'ES') { if (target === 'ES') {
return {ok: false, status: 500, text: async () => 'boom'} return {ok: false, status: 500, text: async () => 'boom'};
} }
return fakeDeeplResponse(`traduit-${target}`) return fakeDeeplResponse(`traduit-${target}`);
}) });
const strapi = { const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})), contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
log: {error: vi.fn()} log: {error: vi.fn()}
} };
const service = createService({strapi}) const service = createService({strapi});
const result = await service.translateLyrics('Bonjour le monde') const result = await service.translateLyrics('Bonjour le monde');
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);
});
});
+121 -104
View File
@@ -1,56 +1,72 @@
'use strict'; 'use strict';
const qs = require('qs') const Diff = require('diff');
const Diff = require('diff')
const { createCoreService } = require('@strapi/strapi').factories; const { createCoreService } = require('@strapi/strapi').factories;
const { ApplicationError } = require("@strapi/utils").errors 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 '';
return text return text
.replace(/#{1,6}\s+/g, '') .replace(/#{1,6}\s+/g, '')
.replace(/\*\*(.*?)\*\*/gs, '$1') .replace(/\*\*(.*?)\*\*/gs, '$1')
.replace(/\*(.*?)\*/gs, '$1') .replace(/\*(.*?)\*/gs, '$1')
.replace(/__(.*?)__/gs, '$1') .replace(/__(.*?)__/gs, '$1')
.replace(/_(.*?)_/gs, '$1') .replace(/_(.*?)_/gs, '$1')
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-\*\+]\s+/gm, '') .replace(/^[>\-*+]\s+/gm, '')
.replace(/\n{3,}/g, '\n\n') .replace(/\n{3,}/g, '\n\n')
.trim() .trim();
} }
// Détecte si une transcription est probablement en français plutôt qu'en KA. // Détecte si une transcription est probablement en français plutôt qu'en KA.
// Heuristique : si les pronoms personnels français représentent > 4 % des mots. // Heuristique : si les pronoms personnels français représentent > 4 % des mots.
const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']) const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']);
function suspectFrench(text) { function suspectFrench(text) {
if (!text) return false if (!text) return false;
const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || [] const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || [];
if (words.length < 10) return false if (words.length < 10) return false;
const frCount = words.filter(w => FR_PRONOUNS.has(w)).length const frCount = words.filter(w => FR_PRONOUNS.has(w)).length;
return frCount / words.length > 0.04 return frCount / words.length > 0.04;
} }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
class Translator { class Translator {
constructor() { constructor() {
this.deeplApi = process.env.DEEPL_URL || 'api-free.deepl.com' this.deeplApi = process.env.DEEPL_URL || 'api-free.deepl.com';
this.deeplKey = process.env.DEEPL_KEY this.deeplKey = process.env.DEEPL_KEY;
this.urlRequest = `https://${this.deeplApi}/v2/translate` this.urlRequest = `https://${this.deeplApi}/v2/translate`;
} }
async get(origin, target, text) { async get(origin, target, text) {
@@ -66,40 +82,40 @@ class Translator {
target_lang: target, target_lang: target,
}), }),
signal: AbortSignal.timeout(15_000) signal: AbortSignal.timeout(15_000)
}) });
if (!response.ok) { if (!response.ok) {
const body = await response.text() const body = await response.text();
console.error('DeepL error:', body) console.error('DeepL error:', body);
throw new Error(`DeepL ${response.status}: ${body}`) throw new Error(`DeepL ${response.status}: ${body}`);
} }
return await response.json() return await response.json();
} }
} }
const translator = new Translator() const translator = new Translator();
module.exports = createCoreService('api::parole.parole', ({strapi}) => ({ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
async translate(origin, target, text) { async translate(origin, target, text) {
const data = await translator.get(origin, target, text) const data = await translator.get(origin, target, text);
return data.translations[0].text return data.translations[0].text;
}, },
async translateLyrics(parolesFR) { async translateLyrics(parolesFR) {
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);
result[field] = translated + suffix result[field] = translated + suffix;
} catch (err) { } catch (err) {
strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`) strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`);
} }
} }
return result return result;
}, },
validateParoles(titre, transcription) { validateParoles(titre, transcription) {
if (!titre || titre.trim().length === 0) { if (!titre || titre.trim().length === 0) {
@@ -107,77 +123,77 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
} }
if (!transcription || transcription.trim().length === 0) { if (!transcription || transcription.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez renseigner la transcription.') throw new ApplicationError('Champ obligatoire. Veuillez renseigner la transcription.');
} }
if (transcription.trim().length < 10) { if (transcription.trim().length < 10) {
throw new ApplicationError('La transcription doit contenir au moins 10 caractères.') throw new ApplicationError('La transcription doit contenir au moins 10 caractères.');
} }
}, },
async fetchAllParoles() { async fetchAllParoles() {
const pageSize = 100 const pageSize = 100;
let start = 0 let start = 0;
const all = [] const all = [];
while (true) { for (;;) {
const batch = await strapi.documents('api::parole.parole').findMany({ const batch = await strapi.documents('api::parole.parole').findMany({
status: 'published', status: 'published',
populate: ['artistes', 'traductions'], populate: ['artistes', 'traductions'],
fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'], fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'],
limit: pageSize, limit: pageSize,
start, start,
}) });
all.push(...batch) all.push(...batch);
if (batch.length < pageSize) break if (batch.length < pageSize) break;
start += pageSize start += pageSize;
} }
return all return all;
}, },
buildExport(paroles, type, langs) { buildExport(paroles, type, langs) {
const targetLangs = langs && langs.length ? langs : ALL_LANGS const targetLangs = langs && langs.length ? langs : ALL_LANGS;
const pairs = [] const pairs = [];
const missing = [] const missing = [];
const nonKa = [] const nonKa = [];
const langCounts = {} const langCounts = {};
for (const parole of paroles) { for (const parole of paroles) {
const source = stripMarkdown(parole.transcription) const source = stripMarkdown(parole.transcription);
const sourceLang = parole.langueSource || 'ka' const sourceLang = parole.langueSource || 'ka';
const artists = (parole.artistes || []).map(a => a.alias) const artists = (parole.artistes || []).map(a => a.alias);
const paroleMeta = { title: parole.titre, artists } const paroleMeta = { title: parole.titre, artists };
if (sourceLang !== 'ka') { if (sourceLang !== 'ka') {
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang }) nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang });
} else if (suspectFrench(source)) { } else if (suspectFrench(source)) {
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' }) nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' });
} }
const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]) const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]);
if (missingLangs.length > 0) { if (missingLangs.length > 0) {
missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs }) missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs });
} }
for (const lang of targetLangs) { for (const lang of targetLangs) {
const { field, targetLang, userPrompt } = LANG_MAP[lang] const { field, targetLang, userPrompt } = LANG_MAP[lang];
if (lang === sourceLang) continue if (lang === sourceLang) continue;
const target = stripMarkdown(parole.traductions?.[field]) const target = stripMarkdown(parole.traductions?.[field]);
if (!target) continue if (!target) continue;
langCounts[lang] = (langCounts[lang] || 0) + 1 langCounts[lang] = (langCounts[lang] || 0) + 1;
if (type === 'instruct') { if (type === 'instruct') {
const systemPrompt = sourceLang === 'ka' const systemPrompt = sourceLang === 'ka'
? 'Tu es un expert en langue KA (créole guadeloupéen/martiniquais). Traduis le texte KA suivant.' ? 'Tu es un expert en langue KA (créole guadeloupéen/martiniquais). Traduis le texte KA suivant.'
: `Tu es un expert en traduction. Traduis le texte suivant (langue source : ${sourceLang}).` : `Tu es un expert en traduction. Traduis le texte suivant (langue source : ${sourceLang}).`;
pairs.push({ pairs.push({
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: `${userPrompt} :\n\n${source}` }, { role: 'user', content: `${userPrompt} :\n\n${source}` },
{ role: 'assistant', content: target }, { role: 'assistant', content: target },
], ],
}) });
} else { } else {
pairs.push({ pairs.push({
source_lang: sourceLang, source_lang: sourceLang,
@@ -185,7 +201,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
source, source,
target, target,
...paroleMeta, ...paroleMeta,
}) });
} }
} }
} }
@@ -197,60 +213,61 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
languages: langCounts, languages: langCounts,
missing_translations: missing, missing_translations: missing,
non_ka_transcriptions: nonKa, non_ka_transcriptions: nonKa,
} };
return { metadata, pairs } return { metadata, pairs };
}, },
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;
let start = 0 let start = 0;
const all = [] const all = [];
while (true) { for (;;) {
const batch = await strapi.documents('api::parole.parole').findMany({ const batch = await strapi.documents('api::parole.parole').findMany({
status: 'published', status: 'published',
populate: ['traductions'], populate: ['traductions'],
fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'], fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'],
limit: pageSize, limit: pageSize,
start, start,
}) });
all.push(...batch) all.push(...batch);
if (batch.length < pageSize) break if (batch.length < pageSize) break;
start += pageSize start += pageSize;
} }
const translated = [] const translated = [];
const skipped = [] const skipped = [];
const errors = [] const errors = [];
for (const parole of all) { for (const parole of all) {
const sourceFR = parole.traductions?.francais const sourceFR = parole.traductions?.francais
|| (parole.langueSource === 'fr' ? parole.transcription : null) || (parole.langueSource === 'fr' ? parole.transcription : null);
if (!sourceFR) { skipped.push(parole.slug); continue } if (!sourceFR) { skipped.push(parole.slug); continue; }
const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]) const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]);
if (missing.length === 0) { skipped.push(parole.slug); continue } if (missing.length === 0) { skipped.push(parole.slug); continue; }
const { id: _id, ...tradData } = parole.traductions || {} // eslint-disable-next-line no-unused-vars -- id exclu intentionnellement du spread
const updatedTrad = { ...tradData } const { id: _id, ...tradData } = parole.traductions || {};
const addedLangs = [] const updatedTrad = { ...tradData };
const addedLangs = [];
for (const { lang, field, deeplTarget, suffix } of missing) { for (const { lang, field, deeplTarget, suffix } of missing) {
try { try {
await sleep(700) await sleep(700);
const result = await translator.get('FR', deeplTarget, sourceFR) const result = await translator.get('FR', deeplTarget, sourceFR);
const text = result?.translations?.[0]?.text const text = result?.translations?.[0]?.text;
if (text) { if (text) {
updatedTrad[field] = text + suffix updatedTrad[field] = text + suffix;
addedLangs.push(lang) addedLangs.push(lang);
} }
} catch (err) { } catch (err) {
errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message }) errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message });
} }
} }
@@ -258,28 +275,28 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
await strapi.documents('api::parole.parole').update({ await strapi.documents('api::parole.parole').update({
documentId: parole.documentId, documentId: parole.documentId,
data: { traductions: updatedTrad }, data: { traductions: updatedTrad },
}) });
await strapi.documents('api::parole.parole').publish({ await strapi.documents('api::parole.parole').publish({
documentId: parole.documentId, documentId: parole.documentId,
}) });
translated.push({ slug: parole.slug, langs: addedLangs }) translated.push({ slug: parole.slug, langs: addedLangs });
} }
} }
return { translated, skipped, errors } return { translated, skipped, errors };
}, },
parolesDiff(titre = '', oldString, newString) { parolesDiff(titre = '', oldString, newString) {
const patch = Diff.createPatch(titre, oldString, newString, 'supprimée', 'ajoutée') const patch = Diff.createPatch(titre, oldString, newString, 'supprimée', 'ajoutée');
const parsePatch = Diff.parsePatch(patch) const parsePatch = Diff.parsePatch(patch);
if (parsePatch[0].hunks.length > 0) { if (parsePatch[0].hunks.length > 0) {
const jsonDiff = Diff.diffWords(oldString, newString) const jsonDiff = Diff.diffWords(oldString, newString);
return { return {
patch, patch,
jsonDiff jsonDiff
} };
} }
} }
})); }));
+5 -5
View File
@@ -1,15 +1,15 @@
'use strict'; 'use strict';
module.exports = { module.exports = {
async count(ctx, next) { async count() {
const countArtiste = await strapi.documents('api::artiste.artiste').count({ const countArtiste = await strapi.documents('api::artiste.artiste').count({
publicationState: 'live' publicationState: 'live'
}) });
const countParole = await strapi.documents('api::parole.parole').count({ const countParole = await strapi.documents('api::parole.parole').count({
publicationState: 'live' publicationState: 'live'
}) });
return {countArtiste, countParole} return {countArtiste, countParole};
}
} }
};
+1 -1
View File
@@ -9,4 +9,4 @@ module.exports = {
} }
} }
] ]
} };
+48 -6
View File
@@ -10,6 +10,54 @@
"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 +72,6 @@
}, },
"portugais": { "portugais": {
"type": "richtext" "type": "richtext"
},
"japonais": {
"type": "richtext"
},
"coreen": {
"type": "richtext"
} }
} }
} }
@@ -1,11 +1,11 @@
import {describe, it, expect, vi} from 'vitest' import {describe, it, expect, vi} from 'vitest';
const {default: isDocumentOwner} = await import('../is-document-owner.js') const {default: isDocumentOwner} = await import('../is-document-owner.js');
function buildStrapi({jwtUserId, document}) { function buildStrapi({jwtUserId, document}) {
const dbQuery = { const dbQuery = {
findOne: vi.fn(async () => document) findOne: vi.fn(async () => document)
} };
return { return {
plugins: { plugins: {
@@ -21,7 +21,7 @@ function buildStrapi({jwtUserId, document}) {
query: vi.fn(() => dbQuery) query: vi.fn(() => dbQuery)
}, },
dbQuery dbQuery
} };
} }
function buildPolicyContext({authorization = 'Bearer faketoken', paramId, bodyDocumentId} = {}) { function buildPolicyContext({authorization = 'Bearer faketoken', paramId, bodyDocumentId} = {}) {
@@ -31,43 +31,43 @@ function buildPolicyContext({authorization = 'Bearer faketoken', paramId, bodyDo
header: authorization ? {authorization} : {}, header: authorization ? {authorization} : {},
body: {data: {documentId: bodyDocumentId}} body: {data: {documentId: bodyDocumentId}}
} }
} };
} }
describe('is-document-owner policy', () => { describe('is-document-owner policy', () => {
it("refuse quand aucun en-tête d'autorisation n'est présent", async () => { it('refuse quand aucun en-tête d\'autorisation n\'est présent', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}}) const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({authorization: null, paramId: 'doc-1'}) const policyContext = buildPolicyContext({authorization: null, paramId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Opération non autorisée') await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Opération non autorisée');
}) });
it('autorise quand le user du JWT est le propriétaire du document (id dans les params)', async () => { it('autorise quand le user du JWT est le propriétaire du document (id dans les params)', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}}) const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({paramId: 'doc-1'}) const policyContext = buildPolicyContext({paramId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).resolves.toBe(true) await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).resolves.toBe(true);
expect(strapi.dbQuery.findOne).toHaveBeenCalledWith({where: {documentId: 'doc-1'}, populate: {user: true}}) expect(strapi.dbQuery.findOne).toHaveBeenCalledWith({where: {documentId: 'doc-1'}, populate: {user: true}});
}) });
it('autorise quand le documentId vient du corps de la requête (cas du contrôleur parole.update)', async () => { it('autorise quand le documentId vient du corps de la requête (cas du contrôleur parole.update)', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}}) const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({bodyDocumentId: 'doc-1'}) const policyContext = buildPolicyContext({bodyDocumentId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).resolves.toBe(true) await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).resolves.toBe(true);
}) });
it("refuse quand le user du JWT n'est pas le propriétaire du document", async () => { it('refuse quand le user du JWT n\'est pas le propriétaire du document', async () => {
const strapi = buildStrapi({jwtUserId: 999, document: {documentId: 'doc-1', user: {id: 1}}}) const strapi = buildStrapi({jwtUserId: 999, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({paramId: 'doc-1'}) const policyContext = buildPolicyContext({paramId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Opération non autorisée') await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Opération non autorisée');
}) });
it("refuse quand le document ciblé n'existe pas", async () => { it('refuse quand le document ciblé n\'existe pas', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: null}) const strapi = buildStrapi({jwtUserId: 1, document: null});
const policyContext = buildPolicyContext({paramId: 'doc-inconnu'}) const policyContext = buildPolicyContext({paramId: 'doc-inconnu'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Ressource introuvable.') await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Ressource introuvable.');
}) });
}) });
+23 -23
View File
@@ -1,6 +1,6 @@
import {describe, it, expect, vi} from 'vitest' import {describe, it, expect, vi} from 'vitest';
const {default: isPayloadOwner} = await import('../is-payload-owner.js') const {default: isPayloadOwner} = await import('../is-payload-owner.js');
function buildStrapi(jwtUserId) { function buildStrapi(jwtUserId) {
return { return {
@@ -13,7 +13,7 @@ function buildStrapi(jwtUserId) {
} }
} }
} }
} };
} }
function buildPolicyContext({authorization, payloadUserId}) { function buildPolicyContext({authorization, payloadUserId}) {
@@ -22,30 +22,30 @@ function buildPolicyContext({authorization, payloadUserId}) {
header: authorization ? {authorization} : {}, header: authorization ? {authorization} : {},
body: {data: {user: {id: payloadUserId}}} body: {data: {user: {id: payloadUserId}}}
} }
} };
} }
describe('is-payload-owner policy', () => { describe('is-payload-owner policy', () => {
it("refuse quand aucun en-tête d'autorisation n'est présent", async () => { it('refuse quand aucun en-tête d\'autorisation n\'est présent', async () => {
const strapi = buildStrapi(999) const strapi = buildStrapi(999);
const policyContext = buildPolicyContext({authorization: undefined, payloadUserId: 1}) const policyContext = buildPolicyContext({authorization: undefined, payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée') await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée');
}) });
it('autorise quand le user du JWT correspond au user du payload', async () => { it('autorise quand le user du JWT correspond au user du payload', async () => {
const strapi = buildStrapi(1) const strapi = buildStrapi(1);
const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1}) const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).resolves.toBe(true) await expect(isPayloadOwner(policyContext, {}, {strapi})).resolves.toBe(true);
}) });
it('refuse quand le user du JWT ne correspond pas au user du payload', async () => { it('refuse quand le user du JWT ne correspond pas au user du payload', async () => {
const strapi = buildStrapi(999) const strapi = buildStrapi(999);
const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1}) const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée') await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée');
}) });
it('refuse quand le token est invalide', async () => { it('refuse quand le token est invalide', async () => {
const strapi = { const strapi = {
@@ -54,15 +54,15 @@ describe('is-payload-owner policy', () => {
services: { services: {
jwt: { jwt: {
getToken: vi.fn(async () => { getToken: vi.fn(async () => {
throw new Error('Invalid token.') throw new Error('Invalid token.');
}) })
} }
} }
} }
} }
} };
const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1}) const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée') await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée');
}) });
}) });
+12 -12
View File
@@ -1,35 +1,35 @@
'use strict'; 'use strict';
const { UnauthorizedError, NotFoundError } = require('@strapi/utils').errors const { UnauthorizedError, NotFoundError } = require('@strapi/utils').errors;
module.exports = async (policyContext, config, {strapi}) => { module.exports = async (policyContext, config, {strapi}) => {
const {request, params} = policyContext const {request, params} = policyContext;
if (!request?.header?.authorization) { if (!request?.header?.authorization) {
throw new UnauthorizedError('Opération non autorisée') throw new UnauthorizedError('Opération non autorisée');
} }
let jwtUserId let jwtUserId;
try { try {
({id: jwtUserId} = await strapi.plugins['users-permissions'].services.jwt.getToken(policyContext)) ({id: jwtUserId} = await strapi.plugins['users-permissions'].services.jwt.getToken(policyContext));
} catch (err) { } catch (err) {
throw new UnauthorizedError('Opération non autorisée') throw new UnauthorizedError('Opération non autorisée');
} }
const documentId = params?.id ?? request.body?.data?.documentId const documentId = params?.id ?? request.body?.data?.documentId;
const document = await strapi.db.query(config.uid).findOne({ const document = await strapi.db.query(config.uid).findOne({
where: {documentId}, where: {documentId},
populate: {user: true} populate: {user: true}
}) });
if (!document) { if (!document) {
throw new NotFoundError('Ressource introuvable.') throw new NotFoundError('Ressource introuvable.');
} }
if (document.user?.id !== jwtUserId) { if (document.user?.id !== jwtUserId) {
throw new UnauthorizedError('Opération non autorisée') throw new UnauthorizedError('Opération non autorisée');
} }
return true return true;
} };
+8 -8
View File
@@ -1,23 +1,23 @@
'use strict'; 'use strict';
const { UnauthorizedError } = require('@strapi/utils').errors const { UnauthorizedError } = require('@strapi/utils').errors;
module.exports = async (policyContext, config, {strapi}) => { module.exports = async (policyContext, config, {strapi}) => {
const {request} = policyContext const {request} = policyContext;
if (!request?.header?.authorization) { if (!request?.header?.authorization) {
throw new UnauthorizedError('Opération non autorisée') throw new UnauthorizedError('Opération non autorisée');
} }
try { try {
const {id} = await strapi.plugins['users-permissions'].services.jwt.getToken(policyContext) const {id} = await strapi.plugins['users-permissions'].services.jwt.getToken(policyContext);
if (id !== request.body?.data?.user?.id) { if (id !== request.body?.data?.user?.id) {
throw new UnauthorizedError('Opération non autorisée') throw new UnauthorizedError('Opération non autorisée');
} }
} catch (err) { } catch (err) {
throw new UnauthorizedError('Opération non autorisée') throw new UnauthorizedError('Opération non autorisée');
} }
return true return true;
} };
@@ -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});
});
});
+37 -37
View File
@@ -1,62 +1,62 @@
import {describe, it, expect, afterEach} from 'vitest' import {describe, it, expect, afterEach} from 'vitest';
import fs from 'fs' import fs from 'fs';
import os from 'os' import os from 'os';
import path from 'path' import path from 'path';
import {backupDatabase} from '../backup-database.js' import {backupDatabase} from '../backup-database.js';
const tmpDirs = [] const tmpDirs = [];
function makeTmpDir() { function makeTmpDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-database-test-')) const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-database-test-'));
tmpDirs.push(dir) tmpDirs.push(dir);
return dir return dir;
} }
describe('backupDatabase', () => { describe('backupDatabase', () => {
afterEach(() => { afterEach(() => {
for (const dir of tmpDirs.splice(0)) { for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, {recursive: true, force: true}) fs.rmSync(dir, {recursive: true, force: true});
} }
}) });
it("ne fait rien quand le fichier de base n'existe pas", () => { it('ne fait rien quand le fichier de base n\'existe pas', () => {
const root = makeTmpDir() const root = makeTmpDir();
const result = backupDatabase({ const result = backupDatabase({
dbPath: path.join(root, 'inexistant.db'), dbPath: path.join(root, 'inexistant.db'),
backupsDir: path.join(root, 'backups') backupsDir: path.join(root, 'backups')
}) });
expect(result).toBeNull() expect(result).toBeNull();
expect(fs.existsSync(path.join(root, 'backups'))).toBe(false) expect(fs.existsSync(path.join(root, 'backups'))).toBe(false);
}) });
it('copie le fichier de base dans le dossier de sauvegardes', () => { it('copie le fichier de base dans le dossier de sauvegardes', () => {
const root = makeTmpDir() const root = makeTmpDir();
const dbPath = path.join(root, 'data.db') const dbPath = path.join(root, 'data.db');
fs.writeFileSync(dbPath, 'contenu-de-la-base') fs.writeFileSync(dbPath, 'contenu-de-la-base');
const backupsDir = path.join(root, 'backups') const backupsDir = path.join(root, 'backups');
const result = backupDatabase({dbPath, backupsDir}) const result = backupDatabase({dbPath, backupsDir});
expect(result).not.toBeNull() expect(result).not.toBeNull();
expect(fs.existsSync(result)).toBe(true) expect(fs.existsSync(result)).toBe(true);
expect(fs.readFileSync(result, 'utf8')).toBe('contenu-de-la-base') expect(fs.readFileSync(result, 'utf8')).toBe('contenu-de-la-base');
}) });
it('ne conserve que les 8 sauvegardes les plus récentes', () => { it('ne conserve que les 8 sauvegardes les plus récentes', () => {
const root = makeTmpDir() const root = makeTmpDir();
const dbPath = path.join(root, 'data.db') const dbPath = path.join(root, 'data.db');
fs.writeFileSync(dbPath, 'contenu') fs.writeFileSync(dbPath, 'contenu');
const backupsDir = path.join(root, 'backups') const backupsDir = path.join(root, 'backups');
fs.mkdirSync(backupsDir, {recursive: true}) fs.mkdirSync(backupsDir, {recursive: true});
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
fs.writeFileSync(path.join(backupsDir, `data-2020-01-0${i}.db`), 'ancien') fs.writeFileSync(path.join(backupsDir, `data-2020-01-0${i}.db`), 'ancien');
} }
backupDatabase({dbPath, backupsDir}) backupDatabase({dbPath, backupsDir});
const remaining = fs.readdirSync(backupsDir).filter(name => name.startsWith('data-')) const remaining = fs.readdirSync(backupsDir).filter(name => name.startsWith('data-'));
expect(remaining).toHaveLength(8) expect(remaining).toHaveLength(8);
}) });
}) });
@@ -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);
});
});
+36
View File
@@ -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};
+44
View File
@@ -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};
+71
View File
@@ -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};
+14
View File
@@ -109,12 +109,26 @@ 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;
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;
}; };
} }
+14 -5
View File
@@ -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;
@@ -521,18 +530,19 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
}; };
options: { options: {
draftAndPublish: true; draftAndPublish: true;
populateCreatorFields: true;
}; };
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'
>; >;
couverture: Schema.Attribute.Media<'images'>; couverture: Schema.Attribute.Media<'images'>;
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'>;
Schema.Attribute.Private;
creativeCommons: Schema.Attribute.Enumeration< creativeCommons: Schema.Attribute.Enumeration<
['BY', 'BY-SA', 'BY-ND', 'BY-NC', 'BY-NC-SA', 'BY-NC-ND'] ['BY', 'BY-SA', 'BY-ND', 'BY-NC', 'BY-NC-SA', 'BY-NC-ND']
>; >;
@@ -576,8 +586,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
traductions: Schema.Attribute.Component<'trad.traductions', false>; traductions: Schema.Attribute.Component<'trad.traductions', false>;
transcription: Schema.Attribute.RichText & Schema.Attribute.Required; transcription: Schema.Attribute.RichText & Schema.Attribute.Required;
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;
user: Schema.Attribute.Relation< user: Schema.Attribute.Relation<
'oneToOne', 'oneToOne',
'plugin::users-permissions.user' 'plugin::users-permissions.user'
+328 -10
View File
@@ -1036,6 +1036,38 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
"@eslint-community/eslint-utils@^4.2.0":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.6.1":
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/eslintrc@^2.1.4":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad"
integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==
dependencies:
ajv "^6.12.4"
debug "^4.3.2"
espree "^9.6.0"
globals "^13.19.0"
ignore "^5.2.0"
import-fresh "^3.2.1"
js-yaml "^4.1.0"
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@8.57.1":
version "8.57.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
"@floating-ui/core@^1.0.5": "@floating-ui/core@^1.0.5":
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.1.0.tgz#0a1dee4bbce87ff71602625d33f711cafd8afc08" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.1.0.tgz#0a1dee4bbce87ff71602625d33f711cafd8afc08"
@@ -1169,6 +1201,25 @@
resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7"
integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==
"@humanwhocodes/config-array@^0.13.0":
version "0.13.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748"
integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==
dependencies:
"@humanwhocodes/object-schema" "^2.0.3"
debug "^4.3.1"
minimatch "^3.0.5"
"@humanwhocodes/module-importer@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
"@humanwhocodes/object-schema@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
"@img/sharp-darwin-arm64@0.33.5": "@img/sharp-darwin-arm64@0.33.5":
version "0.33.5" version "0.33.5"
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08"
@@ -1507,7 +1558,7 @@
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
"@nodelib/fs.walk@^1.2.3": "@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8":
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
@@ -3818,6 +3869,11 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
"@ungap/structured-clone@^1.2.0":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.2.tgz#a03ad82cd5676414d068ba86f880c5681194aadf"
integrity sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==
"@vercel/oidc@3.0.5": "@vercel/oidc@3.0.5":
version "3.0.5" version "3.0.5"
resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.0.5.tgz#bd8db7ee777255c686443413492db4d98ef49657" resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.0.5.tgz#bd8db7ee777255c686443413492db4d98ef49657"
@@ -4044,6 +4100,11 @@ acorn-import-phases@^1.0.3:
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn-walk@^8.0.0: acorn-walk@^8.0.0:
version "8.3.0" version "8.3.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.0.tgz#2097665af50fd0cf7a2dfccd2b9368964e66540f" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.0.tgz#2097665af50fd0cf7a2dfccd2b9368964e66540f"
@@ -4064,6 +4125,11 @@ acorn@^8.5.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
acorn@^8.9.0:
version "8.17.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe"
integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==
addressparser@1.0.1: addressparser@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746" resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746"
@@ -4126,6 +4192,16 @@ ajv@8.18.0, ajv@^8.9.0:
json-schema-traverse "^1.0.0" json-schema-traverse "^1.0.0"
require-from-string "^2.0.2" require-from-string "^2.0.2"
ajv@^6.12.4:
version "6.15.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ajv@^6.12.5: ajv@^6.12.5:
version "6.12.6" version "6.12.6"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -4659,7 +4735,7 @@ chai@^6.2.2:
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e"
integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
chalk@4.1.2, chalk@^4.1.0, chalk@^4.1.2: chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -5154,6 +5230,15 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.3:
shebang-command "^2.0.0" shebang-command "^2.0.0"
which "^2.0.1" which "^2.0.1"
cross-spawn@^7.0.2:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
which "^2.0.1"
crypto-random-string@^2.0.0: crypto-random-string@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
@@ -5304,6 +5389,11 @@ deep-extend@^0.6.0:
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^2.1.1: deepmerge@^2.1.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170"
@@ -5450,6 +5540,13 @@ dnd-core@^16.0.1:
"@react-dnd/invariant" "^4.0.1" "@react-dnd/invariant" "^4.0.1"
redux "^4.2.0" redux "^4.2.0"
doctrine@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
dependencies:
esutils "^2.0.2"
dom-accessibility-api@^0.5.9: dom-accessibility-api@^0.5.9:
version "0.5.16" version "0.5.16"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
@@ -5851,16 +5948,89 @@ eslint-scope@5.1.1:
esrecurse "^4.3.0" esrecurse "^4.3.0"
estraverse "^4.1.1" estraverse "^4.1.1"
eslint-scope@^7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f"
integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==
dependencies:
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint@^8.7.0:
version "8.57.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9"
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@eslint-community/regexpp" "^4.6.1"
"@eslint/eslintrc" "^2.1.4"
"@eslint/js" "8.57.1"
"@humanwhocodes/config-array" "^0.13.0"
"@humanwhocodes/module-importer" "^1.0.1"
"@nodelib/fs.walk" "^1.2.8"
"@ungap/structured-clone" "^1.2.0"
ajv "^6.12.4"
chalk "^4.0.0"
cross-spawn "^7.0.2"
debug "^4.3.2"
doctrine "^3.0.0"
escape-string-regexp "^4.0.0"
eslint-scope "^7.2.2"
eslint-visitor-keys "^3.4.3"
espree "^9.6.1"
esquery "^1.4.2"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^6.0.1"
find-up "^5.0.0"
glob-parent "^6.0.2"
globals "^13.19.0"
graphemer "^1.4.0"
ignore "^5.2.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
is-path-inside "^3.0.3"
js-yaml "^4.1.0"
json-stable-stringify-without-jsonify "^1.0.1"
levn "^0.4.1"
lodash.merge "^4.6.2"
minimatch "^3.1.2"
natural-compare "^1.4.0"
optionator "^0.9.3"
strip-ansi "^6.0.1"
text-table "^0.2.0"
esm@^3.2.25: esm@^3.2.25:
version "3.2.25" version "3.2.25"
resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10"
integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==
espree@^9.6.0, espree@^9.6.1:
version "9.6.1"
resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f"
integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==
dependencies:
acorn "^8.9.0"
acorn-jsx "^5.3.2"
eslint-visitor-keys "^3.4.1"
esprima@^4.0.0, esprima@~4.0.0: esprima@^4.0.0, esprima@~4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.4.2:
version "1.7.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
dependencies:
estraverse "^5.1.0"
esrecurse@^4.3.0: esrecurse@^4.3.0:
version "4.3.0" version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
@@ -5873,6 +6043,11 @@ estraverse@^4.1.1:
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
estraverse@^5.1.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
estraverse@^5.2.0: estraverse@^5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
@@ -5890,6 +6065,11 @@ estree-walker@^3.0.3:
dependencies: dependencies:
"@types/estree" "^1.0.0" "@types/estree" "^1.0.0"
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
events@^3.2.0: events@^3.2.0:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
@@ -5963,6 +6143,11 @@ fast-json-stable-stringify@^2.0.0:
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
@@ -5990,6 +6175,13 @@ fecha@^4.2.0:
resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd"
integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==
file-entry-cache@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==
dependencies:
flat-cache "^3.0.4"
file-selector@^2.1.0: file-selector@^2.1.0:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4"
@@ -6097,6 +6289,20 @@ flagged-respawn@^2.0.0:
resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-2.0.0.tgz#abf39719dcfe1ac06c86c9466081c541c682987b" resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-2.0.0.tgz#abf39719dcfe1ac06c86c9466081c541c682987b"
integrity sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA== integrity sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==
flat-cache@^3.0.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee"
integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==
dependencies:
flatted "^3.2.9"
keyv "^4.5.3"
rimraf "^3.0.2"
flatted@^3.2.9:
version "3.4.2"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
flow-parser@0.*: flow-parser@0.*:
version "0.309.0" version "0.309.0"
resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.309.0.tgz#ca2eae0b1a604cafbba99863785a92f7164671ee" resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.309.0.tgz#ca2eae0b1a604cafbba99863785a92f7164671ee"
@@ -6406,6 +6612,13 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
dependencies: dependencies:
is-glob "^4.0.1" is-glob "^4.0.1"
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob-to-regexp@^0.4.1: glob-to-regexp@^0.4.1:
version "0.4.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
@@ -6462,6 +6675,13 @@ global-prefix@^1.0.1:
is-windows "^1.0.1" is-windows "^1.0.1"
which "^1.2.14" which "^1.2.14"
globals@^13.19.0:
version "13.24.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171"
integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==
dependencies:
type-fest "^0.20.2"
globalthis@^1.0.2: globalthis@^1.0.2:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236"
@@ -6521,6 +6741,11 @@ grant@5.4.24:
jwk-to-pem "^2.0.7" jwk-to-pem "^2.0.7"
jws "^4.0.0" jws "^4.0.0"
graphemer@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
gzip-size@^6.0.0: gzip-size@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"
@@ -6868,6 +7093,11 @@ ignore-by-default@^1.0.1:
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
immediate@~3.0.5: immediate@~3.0.5:
version "3.0.6" version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
@@ -7088,6 +7318,13 @@ is-generator-function@^1.0.7:
resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.8.tgz#dfb5c2b120e02b0a8d9d2c6806cd5621aa922f7b" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.8.tgz#dfb5c2b120e02b0a8d9d2c6806cd5621aa922f7b"
integrity sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ== integrity sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==
is-glob@^4.0.0, is-glob@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-glob@^4.0.1, is-glob@~4.0.1: is-glob@^4.0.1, is-glob@~4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
@@ -7095,13 +7332,6 @@ is-glob@^4.0.1, is-glob@~4.0.1:
dependencies: dependencies:
is-extglob "^2.1.1" is-extglob "^2.1.1"
is-glob@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-hexadecimal@^2.0.0: is-hexadecimal@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027" resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027"
@@ -7132,6 +7362,11 @@ is-obj@^2.0.0:
resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
is-path-inside@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
is-plain-obj@^4.0.0: is-plain-obj@^4.0.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
@@ -7268,6 +7503,13 @@ js-yaml@^3.13.0:
argparse "^1.0.7" argparse "^1.0.7"
esprima "^4.0.0" esprima "^4.0.0"
js-yaml@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
dependencies:
argparse "^2.0.1"
jscodeshift@17.3.0: jscodeshift@17.3.0:
version "17.3.0" version "17.3.0"
resolved "https://registry.yarnpkg.com/jscodeshift/-/jscodeshift-17.3.0.tgz#b9ea1d8d1c9255103bfc4cb42ddb46e18cb2415c" resolved "https://registry.yarnpkg.com/jscodeshift/-/jscodeshift-17.3.0.tgz#b9ea1d8d1c9255103bfc4cb42ddb46e18cb2415c"
@@ -7327,6 +7569,11 @@ json-schema@^0.4.0:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json5@^2.1.2: json5@^2.1.2:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
@@ -7443,6 +7690,13 @@ keyv@^4.0.0:
compress-brotli "^1.3.8" compress-brotli "^1.3.8"
json-buffer "3.0.1" json-buffer "3.0.1"
keyv@^4.5.3:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
dependencies:
json-buffer "3.0.1"
kind-of@^6.0.2: kind-of@^6.0.2:
version "6.0.3" version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
@@ -7611,6 +7865,14 @@ kuler@^2.0.0:
resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
dependencies:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libbase64@0.1.0: libbase64@0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-0.1.0.tgz#62351a839563ac5ff5bd26f12f60e9830bb751e6" resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-0.1.0.tgz#62351a839563ac5ff5bd26f12f60e9830bb751e6"
@@ -7816,6 +8078,11 @@ lodash.isplainobject@4.0.6:
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.sortby@^4.7.0: lodash.sortby@^4.7.0:
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@@ -8526,6 +8793,13 @@ minimatch@^3.0.4:
dependencies: dependencies:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
minimatch@^3.0.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
dependencies:
brace-expansion "^1.1.7"
minimatch@^3.1.2: minimatch@^3.1.2:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -8673,6 +8947,11 @@ napi-build-utils@^1.0.1:
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806"
integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
negotiator@0.6.2: negotiator@0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@@ -8916,6 +9195,18 @@ opener@^1.5.2:
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
dependencies:
deep-is "^0.1.3"
fast-levenshtein "^2.0.6"
levn "^0.4.1"
prelude-ls "^1.2.1"
type-check "^0.4.0"
word-wrap "^1.2.5"
ora@5.4.1, ora@^5.4.1: ora@5.4.1, ora@^5.4.1:
version "5.4.1" version "5.4.1"
resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18"
@@ -9401,6 +9692,11 @@ preferred-pm@3.1.3:
path-exists "^4.0.0" path-exists "^4.0.0"
which-pm "2.0.0" which-pm "2.0.0"
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.3.3: prettier@3.3.3:
version "3.3.3" version "3.3.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105"
@@ -10133,7 +10429,7 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rimraf@3.0.2: rimraf@3.0.2, rimraf@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
@@ -10826,6 +11122,11 @@ strip-final-newline@^2.0.0:
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
strip-json-comments@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
strip-json-comments@~2.0.1: strip-json-comments@~2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
@@ -11009,6 +11310,11 @@ text-hex@1.0.x:
resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
thenify-all@^1.0.0: thenify-all@^1.0.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
@@ -11214,6 +11520,13 @@ tunnel-agent@^0.6.0:
dependencies: dependencies:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
dependencies:
prelude-ls "^1.2.1"
type-fest@^0.20.2: type-fest@^0.20.2:
version "0.20.2" version "0.20.2"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
@@ -11751,6 +12064,11 @@ winston@3.10.0:
triple-beam "^1.3.0" triple-beam "^1.3.0"
winston-transport "^4.5.0" winston-transport "^4.5.0"
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
wordwrap@^1.0.0: wordwrap@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"