@tekkare/romulus 0.1.240 → 0.1.242

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/assets/styles/tokens.css +1 -1
  3. package/layer/app/app.config.ts +90 -0
  4. package/layer/app/assets/tekkare_logo.svg +20 -0
  5. package/layer/app/components/OipDirectusSources.vue +192 -0
  6. package/layer/app/components/OipDocumentPreview.vue +158 -0
  7. package/layer/app/components/OipMetadataEditDialog.vue +350 -0
  8. package/layer/app/components/OipNoteEditDialog.vue +382 -0
  9. package/layer/app/components/OipShell.vue +711 -0
  10. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  11. package/layer/app/components/OipSourceGroups.vue +107 -0
  12. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  13. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  14. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  15. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  16. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  17. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  18. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  19. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  20. package/layer/app/composables/useOipEsIndices.ts +57 -0
  21. package/layer/app/composables/useOipFavorites.ts +69 -0
  22. package/layer/app/composables/useOipNotes.ts +90 -0
  23. package/layer/app/composables/useOipNotify.ts +21 -0
  24. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  25. package/layer/app/layouts/anonymous.vue +6 -0
  26. package/layer/app/layouts/error.vue +38 -0
  27. package/layer/app/notes/categories.ts +46 -0
  28. package/layer/app/notes/types.ts +100 -0
  29. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  30. package/layer/app/types/metadata.ts +38 -0
  31. package/layer/app/types/oip.ts +60 -0
  32. package/layer/app/types/tableGroups.ts +42 -0
  33. package/layer/app/utils/oipRail.ts +82 -0
  34. package/layer/nuxt.config.ts +32 -0
  35. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  36. package/layer/server/api/oip/documents.get.ts +92 -0
  37. package/layer/server/api/oip/es-indices.get.ts +21 -0
  38. package/layer/server/api/oip/favorites.get.ts +107 -0
  39. package/layer/server/api/oip/metadata.get.ts +109 -0
  40. package/layer/server/api/oip/metadata.patch.ts +163 -0
  41. package/layer/server/api/oip/note-options.get.ts +89 -0
  42. package/layer/server/api/oip/note.get.ts +95 -0
  43. package/layer/server/api/oip/notes.get.ts +173 -0
  44. package/layer/server/api/oip/notes.patch.ts +198 -0
  45. package/layer/server/api/oip/sources.patch.ts +145 -0
  46. package/layer/server/api/oip/sources.post.ts +88 -0
  47. package/layer/server/api/oip/table-groups.get.ts +145 -0
  48. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  49. package/layer/server/api/romulus/version.get.ts +4 -0
  50. package/layer/server/utils/oipAuth.ts +69 -0
  51. package/layer/server/utils/oipEsIndices.ts +99 -0
  52. package/layer/server/utils/oipGuards.ts +90 -0
  53. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  54. package/package.json +5 -3
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+ import { defineEventHandler } from "h3";
3
+ import {
4
+ assertOipSuperAdmin,
5
+ oipAppUrls,
6
+ oipCheckRateLimit,
7
+ oipClientIp,
8
+ oipValidateBody,
9
+ } from "../../utils/oipGuards";
10
+
11
+ /**
12
+ * PATCH /api/oip/metadata, servi par le layer OIP.
13
+ *
14
+ * Correction des metadonnees de cette app depuis le panneau « Métadonnées »
15
+ * du rail : la fiche `OipApplication`, ou l'un de ses `OipSubject`.
16
+ *
17
+ * Meme forme que la correction des sources et des notes : le droit est celui
18
+ * de la console d'OIP 7, revalide ici et pas seulement a l'affichage du
19
+ * crayon, et le jeton Directus d'ecriture reste cote serveur.
20
+ *
21
+ * La cible n'est jamais prise au mot : la fiche corrigee est celle dont le
22
+ * `prod_url` est celui de cette app, relue ici, et un sujet ne se corrige que
23
+ * s'il lui appartient. Les deux collections sont communes a tout le portail,
24
+ * un id d'une autre app ne doit pas passer.
25
+ *
26
+ * Un champ absent du corps n'est pas efface. Une traduction absente est
27
+ * creee, une traduction existante est corrigee.
28
+ */
29
+
30
+ const LANGUAGES = ["fr-FR", "en-US"] as const;
31
+ const STATUSES = ["published", "draft", "archived", "ai_generated"] as const;
32
+
33
+ const translation = z.object({
34
+ id: z.number().int().positive().optional(),
35
+ languages_code: z.enum(LANGUAGES),
36
+ name: z.string().max(300).optional(),
37
+ description: z.string().max(20000).nullable().optional(),
38
+ });
39
+
40
+ const schema = z
41
+ .object({
42
+ application: z
43
+ .object({
44
+ status: z.enum(STATUSES).optional(),
45
+ prod_url: z.string().url().max(500).optional(),
46
+ staging_url: z.string().url().max(500).nullable().optional(),
47
+ github_front_url: z.string().url().max(500).nullable().optional(),
48
+ categories: z.array(z.string().max(100)).max(30).optional(),
49
+ translations: z.array(translation).max(LANGUAGES.length).optional(),
50
+ })
51
+ .optional(),
52
+ subject: z
53
+ .object({
54
+ id: z.string().uuid(),
55
+ status: z.enum(STATUSES).optional(),
56
+ relative_path: z.string().max(300).optional(),
57
+ keywords: z.string().max(5000).nullable().optional(),
58
+ is_specialized_report: z.boolean().optional(),
59
+ translations: z.array(translation).max(LANGUAGES.length).optional(),
60
+ })
61
+ .optional(),
62
+ })
63
+ .refine((body) => body.application || body.subject, { message: "nothing to update" });
64
+
65
+ export default defineEventHandler(async (event) => {
66
+ oipCheckRateLimit(`metadata-write:${oipClientIp(event)}`, 30, 15 * 60 * 1000);
67
+
68
+ await assertOipSuperAdmin(event);
69
+ const appUrl = oipAppUrls()[0];
70
+
71
+ const body = await oipValidateBody(event, schema);
72
+ const config = useRuntimeConfig();
73
+
74
+ const base = (config.directusSourcesWriteUrl as string | undefined)?.replace(/\/$/, "");
75
+ const token = config.directusMetadataWriteToken as string | undefined;
76
+ const readUrl = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
77
+ const readToken = config.directusNotesToken as string | undefined;
78
+ if (!base || !token || !readUrl || !readToken) {
79
+ throw createError({ status: 500, message: "Directus write credentials are not configured" });
80
+ }
81
+
82
+ const write = (method: "PATCH" | "POST", path: string, payload: Record<string, unknown>) =>
83
+ $fetch(`${base}${path}`, {
84
+ method,
85
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
86
+ body: payload,
87
+ }).catch((err: any) => {
88
+ const reason = err?.data?.errors?.[0]?.message || err?.message || "unknown error";
89
+ throw createError({ status: 502, message: `Directus update failed: ${reason}` });
90
+ });
91
+
92
+ const defined = (input: Record<string, unknown>) =>
93
+ Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined));
94
+
95
+ const upsertTranslations = async (
96
+ rows: z.infer<typeof translation>[] | undefined,
97
+ collection: string,
98
+ parentColumn: string,
99
+ parentId: string,
100
+ nameColumn: string,
101
+ descriptionColumn: string
102
+ ) => {
103
+ for (const row of rows ?? []) {
104
+ const payload = defined({ [nameColumn]: row.name, [descriptionColumn]: row.description });
105
+ if (!Object.keys(payload).length) continue;
106
+ if (row.id) {
107
+ await write("PATCH", `/items/${collection}/${row.id}`, payload);
108
+ } else {
109
+ await write("POST", `/items/${collection}`, {
110
+ ...payload,
111
+ [parentColumn]: parentId,
112
+ languages_code: row.languages_code,
113
+ });
114
+ }
115
+ }
116
+ };
117
+
118
+ // L'app de ce portail, relue : c'est elle et elle seule qu'on corrige.
119
+ const apps = await $fetch<{ data: { app_id: string }[] }>(`${readUrl}/items/OipApplication`, {
120
+ headers: { Authorization: `Bearer ${readToken}`, "Cache-Control": "no-store" },
121
+ query: { limit: 1, filter: JSON.stringify({ prod_url: { _eq: appUrl } }), fields: "app_id" },
122
+ });
123
+ const appId = apps.data?.[0]?.app_id;
124
+ if (!appId) throw createError({ status: 404, message: "Application not found in Directus" });
125
+
126
+ if (body.application) {
127
+ const { translations, ...rest } = body.application;
128
+ const fields = defined(rest);
129
+ if (Object.keys(fields).length) await write("PATCH", `/items/OipApplication/${appId}`, fields);
130
+ await upsertTranslations(
131
+ translations,
132
+ "OipApplication_translations",
133
+ "OipApplication_app_id",
134
+ appId,
135
+ "application_name",
136
+ "application_description"
137
+ );
138
+ }
139
+
140
+ if (body.subject) {
141
+ const { id, translations, keywords, ...rest } = body.subject;
142
+ const subject = await $fetch<{ data: { app: string | null } }>(`${readUrl}/items/OipSubject/${id}`, {
143
+ headers: { Authorization: `Bearer ${readToken}`, "Cache-Control": "no-store" },
144
+ query: { fields: "app" },
145
+ }).catch(() => ({ data: { app: null } }));
146
+ if (subject.data?.app !== appId) {
147
+ throw createError({ status: 403, message: "Subject does not belong to this application" });
148
+ }
149
+
150
+ const fields = defined({ ...rest, Keywords: keywords });
151
+ if (Object.keys(fields).length) await write("PATCH", `/items/OipSubject/${id}`, fields);
152
+ await upsertTranslations(
153
+ translations,
154
+ "OipSubject_translations",
155
+ "OipSubject_subject_id",
156
+ id,
157
+ "subject_name",
158
+ "subject_description"
159
+ );
160
+ }
161
+
162
+ return { ok: true };
163
+ });
@@ -0,0 +1,89 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { assertOipSuperAdmin as assertSuperAdmin, oipAppUrls } from "../../utils/oipGuards";
3
+ import type { NoteOptions } from "../../../app/notes/types";
4
+
5
+ /**
6
+ * GET /api/oip/note-options, servi par le layer OIP (correction des notes).
7
+ *
8
+ * De quoi remplir les listes de l'editeur de notes : categories, ecrans de
9
+ * l'app, jeux de donnees.
10
+ *
11
+ * Une note dit a quoi elle se rapporte par ses sujets (les ecrans du portail)
12
+ * et par ses sources. Les choisir demande de connaitre les identifiants
13
+ * Directus, que le navigateur n'a pas : la route les rend deja reduits a un
14
+ * couple valeur / libelle.
15
+ *
16
+ * Deux jetons, parce que deux referentiels : les notes et les sujets se lisent
17
+ * avec celui des notes, les jeux de donnees avec celui des sources, seul a les
18
+ * voir. Aucun des deux ne part dans le navigateur.
19
+ *
20
+ * Reservee a l'equipe qui redige, comme la correction elle-meme.
21
+ */
22
+
23
+ /** L'app du portail dont on veut les ecrans. La meme que /api/notes. */
24
+
25
+ function pickLabel(rows: any[] | null | undefined, code: string) {
26
+ const list = rows ?? [];
27
+ return (list.find((row) => row?.languages_code === code) ?? list[0])?.label ?? "";
28
+ }
29
+
30
+ export default defineEventHandler(async (event): Promise<NoteOptions> => {
31
+ await assertSuperAdmin(event);
32
+
33
+ const config = useRuntimeConfig();
34
+ const notesUrl = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
35
+ const notesToken = config.directusNotesToken as string | undefined;
36
+ const sourcesUrl = (config.directusSourcesWriteUrl as string | undefined)?.replace(/\/+$/, "");
37
+ const sourcesToken = config.directusSourcesWriteToken as string | undefined;
38
+
39
+ if (!notesUrl || !notesToken) {
40
+ throw createError({ status: 503, message: "Notes are not configured" });
41
+ }
42
+
43
+ const notesHeaders = { Authorization: `Bearer ${notesToken}`, "Cache-Control": "no-store" };
44
+
45
+ const [categories, subjects, sources] = await Promise.all([
46
+ $fetch<{ data: any[] }>(`${notesUrl}/items/note_categories`, {
47
+ headers: notesHeaders,
48
+ query: {
49
+ limit: -1,
50
+ fields: "id,translations.languages_code,translations.label",
51
+ },
52
+ }),
53
+ $fetch<{ data: any[] }>(`${notesUrl}/items/OipSubject`, {
54
+ headers: notesHeaders,
55
+ query: {
56
+ limit: -1,
57
+ sort: "relative_path",
58
+ filter: JSON.stringify({ app: { prod_url: { _in: oipAppUrls() } } }),
59
+ fields: "subject_id,relative_path",
60
+ },
61
+ }),
62
+ // Le referentiel des sources est commun a toutes les apps pays : une note
63
+ // peut expliquer un jeu que cette app n'affiche pas encore.
64
+ sourcesUrl && sourcesToken
65
+ ? $fetch<{ data: any[] }>(`${sourcesUrl}/items/DataSource`, {
66
+ headers: { Authorization: `Bearer ${sourcesToken}`, "Cache-Control": "no-store" },
67
+ query: {
68
+ limit: -1,
69
+ sort: "Nickname",
70
+ fields: "source_uuid,Nickname",
71
+ },
72
+ })
73
+ : Promise.resolve({ data: [] as any[] }),
74
+ ]);
75
+
76
+ return {
77
+ categories: (categories.data ?? []).map((row) => ({
78
+ id: row.id,
79
+ fr: pickLabel(row.translations, "fr-FR"),
80
+ en: pickLabel(row.translations, "en-US"),
81
+ })),
82
+ subjects: (subjects.data ?? [])
83
+ .filter((row) => row?.subject_id && row?.relative_path)
84
+ .map((row) => ({ id: row.subject_id, path: row.relative_path })),
85
+ sources: (sources.data ?? [])
86
+ .filter((row) => row?.source_uuid)
87
+ .map((row) => ({ uuid: row.source_uuid, label: row.Nickname || row.source_uuid })),
88
+ };
89
+ });
@@ -0,0 +1,95 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { z } from "zod";
3
+ import { assertOipSuperAdmin as assertSuperAdmin } from "../../utils/oipGuards";
4
+ import { oipParseNoteContent as parseNoteContent } from "../../utils/oipNoteMarkup";
5
+ import type { AdminNote, AdminNoteTranslation } from "../../../app/notes/types";
6
+
7
+ /**
8
+ * GET /api/oip/note, servi par le layer OIP (correction des notes).
9
+ *
10
+ * Une note entiere, pour la corriger depuis le rail.
11
+ *
12
+ * /api/notes rend ce qu'il faut pour lire : une langue, deja triee, sans les
13
+ * identifiants Directus. Corriger demande l'inverse — les deux langues cote a
14
+ * cote, l'id de chaque traduction, les lignes de liaison vers les sujets et
15
+ * les sources — et ca ne regarde que l'equipe qui redige. D'ou une route a
16
+ * part, derriere le meme portillon que l'ecriture.
17
+ *
18
+ * Le contenu redescend en blocs, pas en texte balise : l'editeur travaille sur
19
+ * les memes blocs que le panneau, et c'est le serveur qui sait traduire les
20
+ * deux formes l'une dans l'autre.
21
+ */
22
+
23
+ const schema = z.object({ id: z.string().uuid() });
24
+
25
+ /** Les deux langues que les apps lisent. Le reste du referentiel ne sert pas ici. */
26
+ const LANGUAGES = ["fr-FR", "en-US"] as const;
27
+
28
+ interface DirectusTranslation {
29
+ id?: number;
30
+ languages_code?: string;
31
+ title?: string | null;
32
+ summary?: string | null;
33
+ content?: string | null;
34
+ }
35
+
36
+ export default defineEventHandler(async (event): Promise<AdminNote> => {
37
+ await assertSuperAdmin(event);
38
+
39
+ const parsed = schema.safeParse(getQuery(event));
40
+ if (!parsed.success) throw createError({ status: 400, message: "A note id is required" });
41
+
42
+ const config = useRuntimeConfig();
43
+ const url = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
44
+ const token = config.directusNotesToken as string | undefined;
45
+ if (!url || !token) throw createError({ status: 503, message: "Notes are not configured" });
46
+
47
+ const response = await $fetch<{ data: any }>(`${url}/items/Notes/${parsed.data.id}`, {
48
+ headers: { Authorization: `Bearer ${token}`, "Cache-Control": "no-store" },
49
+ query: {
50
+ fields: [
51
+ "id",
52
+ "status",
53
+ "category",
54
+ "translations.id",
55
+ "translations.languages_code",
56
+ "translations.title",
57
+ "translations.summary",
58
+ "translations.content",
59
+ "linked_subjects.id",
60
+ "linked_subjects.OipSubject_subject_id",
61
+ "linked_sources.id",
62
+ "linked_sources.DataSource_source_uuid",
63
+ ].join(","),
64
+ },
65
+ });
66
+
67
+ const row = response.data;
68
+ if (!row) throw createError({ status: 404, message: "Note not found" });
69
+
70
+ const rows: DirectusTranslation[] = row.translations ?? [];
71
+
72
+ return {
73
+ id: row.id,
74
+ status: row.status ?? "draft",
75
+ category: row.category ?? null,
76
+ // Les deux langues sortent toujours, meme absentes du CMS : l'editeur doit
77
+ // pouvoir ecrire celle qui manque sans passer par Directus.
78
+ translations: LANGUAGES.map((code) => {
79
+ const found = rows.find((item) => item.languages_code === code);
80
+ return {
81
+ ...(found?.id ? { id: found.id } : {}),
82
+ languages_code: code,
83
+ title: found?.title ?? "",
84
+ summary: found?.summary ?? "",
85
+ sections: parseNoteContent(found?.content ?? ""),
86
+ };
87
+ }),
88
+ subjects: (row.linked_subjects ?? [])
89
+ .filter((link: any) => link?.OipSubject_subject_id)
90
+ .map((link: any) => ({ linkId: link.id, subjectId: link.OipSubject_subject_id })),
91
+ sources: (row.linked_sources ?? [])
92
+ .filter((link: any) => link?.DataSource_source_uuid)
93
+ .map((link: any) => ({ linkId: link.id, sourceUuid: link.DataSource_source_uuid })),
94
+ };
95
+ });
@@ -0,0 +1,173 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { oipAppUrls } from "../../utils/oipGuards";
3
+ import { oipParseNoteContent as parseNoteContent } from "../../utils/oipNoteMarkup";
4
+ import type { ApiNote } from "../../../app/notes/types";
5
+
6
+ /**
7
+ * GET /api/oip/notes, servi par le layer OIP (panneau « Notes »).
8
+ *
9
+ * Les notes du rail, lues dans Directus (collection `Notes`).
10
+ *
11
+ * Elles etaient ecrites en dur dans `app/notes/*.ts` : une correction de texte
12
+ * demandait un deploiement. Elles vivent maintenant dans le CMS, ou l'equipe
13
+ * data les ecrit et les traduit.
14
+ *
15
+ * Une note dit a quoi elle se rapporte de deux facons, et il en faut au moins
16
+ * une : ses `linked_subjects`, les sujets du portail OIP qui portent le chemin
17
+ * de l'ecran (`relative_path`), et ses `linked_sources`, les jeux Directus
18
+ * qu'elle explique. Une note attachee a une source sort partout ou cette
19
+ * source est citee : la note qui dit ce que couvre Medicam suit Medicam, sans
20
+ * qu'on ait a lister les ecrans qui l'affichent.
21
+ *
22
+ * Une note qui ne cite ni sujet ni source ne sort nulle part : un champ vide
23
+ * veut dire « pas encore rattachee », et le panneau ne peut pas le distinguer
24
+ * de « vaut pour toute l'app ». Une note transverse se declare, elle ne
25
+ * s'oublie pas.
26
+ *
27
+ * La reponse sort entiere, une fois par langue : 43 notes, c'est moins qu'un
28
+ * aller-retour par page. Le tri par chemin se fait cote client, qui seul sait
29
+ * quelle page est ouverte.
30
+ *
31
+ * Le navigateur ne voit ni l'URL ni le token Directus.
32
+ */
33
+
34
+ const COLLECTION = "Notes";
35
+
36
+ /** L'app du portail dont on veut les notes. Une autre app, un autre `prod_url`. */
37
+
38
+ /**
39
+ * De l'intitule Directus vers la cle de categorie du panneau.
40
+ *
41
+ * Directus ne stocke que le libelle traduit ; l'icone et la teinte restent
42
+ * cote app (cf. `app/notes/categories.ts`), ou elles sont communes aux quatre
43
+ * sous-apps. Les deux langues sont acceptees : le libelle lu depend de la
44
+ * langue demandee.
45
+ */
46
+ const CATEGORY_KEYS: Record<string, string> = {
47
+ "méthodologie": "method",
48
+ "methodology": "method",
49
+ "métriques": "metrics",
50
+ "metrics": "metrics",
51
+ "limites": "limits",
52
+ "limitations": "limits",
53
+ "conventions": "conventions",
54
+ "réglementaire": "regulatory",
55
+ "regulatory": "regulatory",
56
+ "glossaire": "glossary",
57
+ "glossary": "glossary",
58
+ "aide": "help",
59
+ "help": "help",
60
+ };
61
+
62
+ interface DirectusTranslation {
63
+ languages_code?: string;
64
+ title?: string | null;
65
+ summary?: string | null;
66
+ content?: string | null;
67
+ label?: string | null;
68
+ }
69
+
70
+ interface DirectusNote {
71
+ id: string;
72
+ sort?: number | null;
73
+ date_created?: string | null;
74
+ date_updated?: string | null;
75
+ category?: { translations?: DirectusTranslation[] | null } | null;
76
+ translations?: DirectusTranslation[] | null;
77
+ linked_subjects?: {
78
+ OipSubject_subject_id?: { relative_path?: string | null; app?: { prod_url?: string | null } | null } | null;
79
+ }[] | null;
80
+ linked_sources?: { DataSource_source_uuid?: string | null }[] | null;
81
+ }
82
+
83
+ function pick(rows: DirectusTranslation[] | null | undefined, lang: string) {
84
+ const list = rows ?? [];
85
+ const wanted = lang === "en" ? "en-US" : "fr-FR";
86
+ return (
87
+ list.find((row) => row.languages_code === wanted) ??
88
+ list.find((row) => row.languages_code === "fr-FR") ??
89
+ list[0]
90
+ );
91
+ }
92
+
93
+ export default defineEventHandler(async (event): Promise<ApiNote[]> => {
94
+ const config = useRuntimeConfig();
95
+ const { lang } = getQuery(event) as { lang?: string };
96
+ const locale = lang === "en" ? "en" : "fr";
97
+
98
+ const appUrls = oipAppUrls();
99
+ const url = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
100
+ const token = config.directusNotesToken as string | undefined;
101
+
102
+ if (!url || !token) {
103
+ throw createError({
104
+ status: 503,
105
+ message:
106
+ "Notes are not configured: set DIRECTUS_URL and DIRECTUS_NOTES_TOKEN " +
107
+ "(or DIRECTUS_FAVORITES_TOKEN). The token needs read access on the Notes collection.",
108
+ });
109
+ }
110
+
111
+ const response = await $fetch<{ data: DirectusNote[] }>(`${url}/items/${COLLECTION}`, {
112
+ // Cette instance Directus garde ses reponses cinq minutes. Une note
113
+ // corrigee, rattachee ou depubliee doit se voir au rechargement, pas au
114
+ // quart d'heure suivant.
115
+ headers: { Authorization: `Bearer ${token}`, "Cache-Control": "no-store" },
116
+ query: {
117
+ limit: -1,
118
+ sort: "sort",
119
+ filter: JSON.stringify({ status: { _eq: "published" } }),
120
+ fields: [
121
+ "id",
122
+ "sort",
123
+ "date_created",
124
+ "date_updated",
125
+ "category.translations.languages_code",
126
+ "category.translations.label",
127
+ "translations.languages_code",
128
+ "translations.title",
129
+ "translations.summary",
130
+ "translations.content",
131
+ "linked_subjects.OipSubject_subject_id.relative_path",
132
+ "linked_subjects.OipSubject_subject_id.app.prod_url",
133
+ "linked_sources.DataSource_source_uuid",
134
+ ].join(","),
135
+ },
136
+ });
137
+
138
+ const notes: ApiNote[] = [];
139
+
140
+ for (const row of response.data ?? []) {
141
+ const text = pick(row.translations, locale);
142
+ const title = (text?.title ?? "").trim();
143
+ if (!title) continue;
144
+
145
+ // Un sujet d'une autre app ne compte pas : la collection est commune a
146
+ // tout le portail. Sans sujet de cette app, la note n'est pas la sienne.
147
+ const paths = (row.linked_subjects ?? [])
148
+ .map((link) => link.OipSubject_subject_id)
149
+ .filter((subject) => appUrls.includes(subject?.app?.prod_url))
150
+ .map((subject) => (subject!.relative_path ?? "").trim())
151
+ .filter(Boolean);
152
+ const sources = (row.linked_sources ?? [])
153
+ .map((link) => (link.DataSource_source_uuid ?? "").trim())
154
+ .filter(Boolean);
155
+
156
+ if (paths.length === 0 && sources.length === 0) continue;
157
+
158
+ const label = (pick(row.category?.translations, locale)?.label ?? "").trim();
159
+
160
+ notes.push({
161
+ id: row.id,
162
+ category: CATEGORY_KEYS[label.toLowerCase()] ?? "help",
163
+ updated: (row.date_updated || row.date_created || "").slice(0, 10),
164
+ title,
165
+ summary: (text?.summary ?? "").trim(),
166
+ sections: parseNoteContent(text?.content ?? ""),
167
+ paths,
168
+ sources,
169
+ });
170
+ }
171
+
172
+ return notes;
173
+ });