@sylad/cadence 1.7.0 → 1.9.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -8
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +12 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +295 -29
- package/dist/config.js +147 -5
- package/dist/corrections.js +78 -22
- package/dist/deliver.js +85 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +620 -0
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +82 -0
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/corrections.md +1 -1
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +37 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +89 -0
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +4 -0
- package/package.json +3 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +31 -6
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +6 -3
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
package/dist/plan.js
CHANGED
|
@@ -6,7 +6,18 @@ export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
|
|
|
6
6
|
export const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
|
|
7
7
|
export class RafError extends Error {
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
/** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
|
|
10
|
+
export const RESOURCE_NAME = /^[a-z0-9_-]+$/;
|
|
11
|
+
export const RESOURCES_BAD_LIST = 'doit être une liste de noms de ressources';
|
|
12
|
+
export const invalidResource = (name) => `ressource invalide : ${name === '' ? '(vide)' : name} (lettres minuscules, chiffres, - et _)`;
|
|
13
|
+
/** Les noms de ressources de `--uses` ou de `raf uses` : chacun valide (`RafError` sinon), sans doublon, dans l'ordre donné. */
|
|
14
|
+
export function cleanUses(names) {
|
|
15
|
+
for (const n of names)
|
|
16
|
+
if (!RESOURCE_NAME.test(n))
|
|
17
|
+
throw new RafError(invalidResource(n));
|
|
18
|
+
return [...new Set(names)];
|
|
19
|
+
}
|
|
20
|
+
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
|
|
10
21
|
/** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
|
|
11
22
|
export const SCREENS = ['page', 'component', 'journey'];
|
|
12
23
|
export function isOpen(status) {
|
|
@@ -238,8 +249,8 @@ export class Plan {
|
|
|
238
249
|
return this.enableGate('reviewSince', today);
|
|
239
250
|
}
|
|
240
251
|
/** `commit` : sha du dernier commit compté du lot (le plan ne lit pas git), null s'il n'en a aucun. */
|
|
241
|
-
recordReview(lotId, verdict, today, commit, repos) {
|
|
242
|
-
this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}) });
|
|
252
|
+
recordReview(lotId, verdict, today, commit, repos, reporte) {
|
|
253
|
+
this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}), ...(reporte ? { reporte } : {}) });
|
|
243
254
|
}
|
|
244
255
|
enableGate(key, today) {
|
|
245
256
|
this.writable();
|
|
@@ -326,11 +337,15 @@ export class Plan {
|
|
|
326
337
|
entry.every = opts.every;
|
|
327
338
|
if (opts.after?.length)
|
|
328
339
|
entry.after = opts.after;
|
|
340
|
+
if (opts.uses?.length)
|
|
341
|
+
entry.uses = cleanUses(opts.uses);
|
|
329
342
|
entry.created = today;
|
|
330
343
|
const node = this.doc.createNode(entry);
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
344
|
+
for (const key of ['after', 'uses']) {
|
|
345
|
+
const seq = node.get(key);
|
|
346
|
+
if (isSeq(seq))
|
|
347
|
+
seq.flow = true;
|
|
348
|
+
}
|
|
334
349
|
this.doc.get('lots').add(node);
|
|
335
350
|
return id;
|
|
336
351
|
}
|
|
@@ -341,6 +356,28 @@ export class Plan {
|
|
|
341
356
|
node.flow = true;
|
|
342
357
|
this.lotNode(lotId).set('repos', node);
|
|
343
358
|
}
|
|
359
|
+
/** Remplace les ressources partagées d'un lot de tout statut (clé `uses:` en flow, avant `created:`) ; sans nom, retire la clé. Jamais sur une sous-tâche. */
|
|
360
|
+
setUses(ref, names) {
|
|
361
|
+
this.writable();
|
|
362
|
+
if (ref.includes('/'))
|
|
363
|
+
throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${ref}`);
|
|
364
|
+
const node = this.lotNode(ref);
|
|
365
|
+
const uses = cleanUses(names);
|
|
366
|
+
if (uses.length === 0) {
|
|
367
|
+
node.delete('uses');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const list = this.doc.createNode(uses);
|
|
371
|
+
list.flow = true;
|
|
372
|
+
if (node.has('uses')) {
|
|
373
|
+
node.set('uses', list);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const keys = node.items.map((p) => String(p.key?.value ?? p.key));
|
|
377
|
+
const created = keys.indexOf('created');
|
|
378
|
+
const after = keys.indexOf('after');
|
|
379
|
+
node.items.splice(created >= 0 ? created : after >= 0 ? after + 1 : keys.length, 0, this.doc.createPair('uses', list));
|
|
380
|
+
}
|
|
344
381
|
addTask(lotId, title) {
|
|
345
382
|
this.writable();
|
|
346
383
|
const lot = this.lotNode(lotId);
|
|
@@ -421,6 +458,21 @@ export class Plan {
|
|
|
421
458
|
node.delete('started');
|
|
422
459
|
this.note(ref, reason.trim() ? `remis en attente : ${reason.trim()}` : 'remis en attente', today);
|
|
423
460
|
}
|
|
461
|
+
/** Remplace le titre d'un lot, de tout statut, et note l'ancien titre ; refuse un titre vide ou identique. */
|
|
462
|
+
setTitle(ref, text, today) {
|
|
463
|
+
this.writable();
|
|
464
|
+
if (ref.includes('/'))
|
|
465
|
+
throw new RafError(`raf title se pose sur un lot, pas une sous-tâche : ${ref}`);
|
|
466
|
+
const title = text.trim();
|
|
467
|
+
if (title === '')
|
|
468
|
+
throw new RafError('titre vide — raf title <id> "nouveau titre"');
|
|
469
|
+
const node = this.lotNode(ref);
|
|
470
|
+
const old = String(node.get('title') ?? '');
|
|
471
|
+
if (old === title)
|
|
472
|
+
throw new RafError(`${ref} porte déjà ce titre`);
|
|
473
|
+
node.set('title', title);
|
|
474
|
+
this.note(ref, `titre remplacé : « ${old} »`, today);
|
|
475
|
+
}
|
|
424
476
|
/** Pose, remplace (texte) ou efface (null) le titre public d'un lot ; la clé suit `title`. */
|
|
425
477
|
setPublic(ref, text) {
|
|
426
478
|
this.writable();
|
|
@@ -460,6 +512,27 @@ export class Plan {
|
|
|
460
512
|
const keys = node.items.map((p) => String(p.key?.value ?? p.key));
|
|
461
513
|
node.items.splice(keys.indexOf('visible') + 1, 0, this.doc.createPair('screen', screen));
|
|
462
514
|
}
|
|
515
|
+
/** Pose (true) ou retire (false) le `visible:` d'un lot existant ; retirer est refusé tant que le lot a un `screen:` (un écran est un changement visible). */
|
|
516
|
+
setVisible(ref, on) {
|
|
517
|
+
this.writable();
|
|
518
|
+
if (ref.includes('/'))
|
|
519
|
+
throw new RafError(`visible se pose sur un lot, pas une sous-tâche : ${ref}`);
|
|
520
|
+
const node = this.lotNode(ref);
|
|
521
|
+
const lot = this.lot(ref);
|
|
522
|
+
if (!on) {
|
|
523
|
+
if (lot.screen)
|
|
524
|
+
throw new RafError(`${ref} a screen: ${lot.screen} — retirer d'abord screen: (raf screen ${ref} none), un écran est un changement visible`);
|
|
525
|
+
node.delete('visible');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (node.has('visible')) {
|
|
529
|
+
node.set('visible', true);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const keys = node.items.map((p) => String(p.key?.value ?? p.key));
|
|
533
|
+
const at = Math.max(...['quickwin', 'estimate', 'status'].map((k) => keys.indexOf(k)));
|
|
534
|
+
node.items.splice(at + 1, 0, this.doc.createPair('visible', true));
|
|
535
|
+
}
|
|
463
536
|
/** Pose (true) ou retire (false) le `spec:` d'un lot existant ; `screen: page` l'implique, donc on ne retire pas la spec d'un lot à page (`component` et `journey` suivent le chemin ordinaire depuis L209 : leur `spec: true` est la spec back). Renvoie false quand `screen: page` rendait la pose sans objet. */
|
|
464
537
|
setSpec(ref, on) {
|
|
465
538
|
this.writable();
|
|
@@ -565,6 +638,7 @@ function foreignLots(raw, format) {
|
|
|
565
638
|
notes: typeof notes === 'string' ? [{ text: notes }] : notes,
|
|
566
639
|
repos: pick('repos'),
|
|
567
640
|
gate: pick('gate'),
|
|
641
|
+
goal: pick('goal'),
|
|
568
642
|
}, problems);
|
|
569
643
|
const parent = pick('parent');
|
|
570
644
|
return { lot, parent: parent == null ? undefined : String(parent) };
|
|
@@ -622,6 +696,13 @@ function normalizeLot(raw, problems = []) {
|
|
|
622
696
|
};
|
|
623
697
|
const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
|
|
624
698
|
const last = asDay('last');
|
|
699
|
+
let uses;
|
|
700
|
+
if (raw.uses != null) {
|
|
701
|
+
if (Array.isArray(raw.uses) && raw.uses.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
|
|
702
|
+
uses = [...new Set(raw.uses)];
|
|
703
|
+
else
|
|
704
|
+
problems.push(`uses ${RESOURCES_BAD_LIST}`);
|
|
705
|
+
}
|
|
625
706
|
let screen;
|
|
626
707
|
if (raw.screen != null) {
|
|
627
708
|
if (SCREENS.includes(raw.screen))
|
|
@@ -650,9 +731,11 @@ function normalizeLot(raw, problems = []) {
|
|
|
650
731
|
...(spec ? { spec } : {}),
|
|
651
732
|
...(gate ? { gate } : {}),
|
|
652
733
|
...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
|
|
734
|
+
...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
|
|
653
735
|
...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
|
|
654
736
|
...(last ? { last } : {}),
|
|
655
737
|
after,
|
|
738
|
+
...(uses?.length ? { uses } : {}),
|
|
656
739
|
created: asDay('created'),
|
|
657
740
|
started: asDay('started'),
|
|
658
741
|
finished: asDay('finished'),
|
|
@@ -705,5 +788,8 @@ function asVerdict(raw) {
|
|
|
705
788
|
if (repos.length)
|
|
706
789
|
verdict.repos = Object.fromEntries(repos);
|
|
707
790
|
}
|
|
791
|
+
const r = v.reporte;
|
|
792
|
+
if (r && typeof r === 'object' && !Array.isArray(r) && r.de != null && r.vers != null)
|
|
793
|
+
verdict.reporte = { de: String(r.de), vers: String(r.vers), patchId: String(r.patchId ?? '') };
|
|
708
794
|
return verdict;
|
|
709
795
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { nextUp } from './audit.js';
|
|
3
|
+
import { planLoader } from './config.js';
|
|
4
|
+
import { gitRoot } from './git.js';
|
|
5
|
+
import { findProjects, readDepth } from './lead.js';
|
|
6
|
+
import { decisionPending } from './orchestrate/continue.js';
|
|
7
|
+
import { liveWaves } from './orchestrate/registry.js';
|
|
8
|
+
import { readResources } from './resources.js';
|
|
9
|
+
import { lotFinished, RunStore } from './orchestrate/state.js';
|
|
10
|
+
/** Le dépôt d'un projet : sa racine git, ou son chemin absolu hors de git. */
|
|
11
|
+
const repoOf = (dir) => gitRoot(dir) ?? resolve(dir);
|
|
12
|
+
/**
|
|
13
|
+
* Un lot se reconnaît à son dépôt et à son id, pas au nom du projet : une vague lancée depuis un autre dossier l'enregistre sous `basename(dir)`
|
|
14
|
+
* (le tour d'un dossier parent le nomme par son chemin, `grp/alpha`), et deux projets de même nom de deux dossiers sont deux dépôts.
|
|
15
|
+
*/
|
|
16
|
+
const lotAt = (repo, lot) => `${repo}:${lot}`;
|
|
17
|
+
/**
|
|
18
|
+
* Les projets qui partagent les ressources de `dir` : ceux que trouve `findProjects` sous le dossier du cadence.yaml qui déclare ses `resources:`
|
|
19
|
+
* (le plus proche en remontant, voir `readResources`), sur la profondeur `depth:` de ce fichier (un niveau à défaut) : `P/x` et `P/grp/alpha`
|
|
20
|
+
* partagent le banc que déclare `P/cadence.yaml` (`depth: 2`), d'où qu'on les interroge. Sans ressource déclarée, ou avec un cadence.yaml
|
|
21
|
+
* illisible ou une `depth:` invalide (que leur ligne de tour signale), ce sont les projets du dossier parent, sur un niveau.
|
|
22
|
+
*/
|
|
23
|
+
function sharingProjects(dir) {
|
|
24
|
+
try {
|
|
25
|
+
const { file, names } = readResources(repoOf(dir));
|
|
26
|
+
if (names.length)
|
|
27
|
+
return findProjects(dirname(file), readDepth(dirname(file)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// cadence.yaml illisible ou depth: invalide
|
|
31
|
+
}
|
|
32
|
+
return findProjects(dirname(dir));
|
|
33
|
+
}
|
|
34
|
+
const plansOf = (dir) => planLoader(repoOf(dir))().lots();
|
|
35
|
+
/**
|
|
36
|
+
* Les ressources prises à cet instant : celles des lots `doing` des projets `dirs` et de ceux qui partagent leurs ressources (`sharingProjects` : lancé depuis l'un d'eux,
|
|
37
|
+
* on voit aussi les autres), celles des lots non finis des vagues vivantes (un lot de vague n'est `doing` dans le plan qu'une fois sa session partie)
|
|
38
|
+
* et celles des lots `held` (le tirage d'une vague qui part). Avec leurs lots (`dépôt:lot`, voir `lotAt`), qui ne sont pas à proposer.
|
|
39
|
+
*/
|
|
40
|
+
function taken(dirs, home, held) {
|
|
41
|
+
const resources = new Set();
|
|
42
|
+
const running = new Set();
|
|
43
|
+
for (const dir of new Set([...dirs, ...dirs.flatMap(sharingProjects)])) {
|
|
44
|
+
try {
|
|
45
|
+
for (const l of nextUp(plansOf(dir)).doing)
|
|
46
|
+
for (const r of l.uses ?? [])
|
|
47
|
+
resources.add(r);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// plan illisible : sa ligne de tour le dit, il ne tient aucune ressource connue
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const w of liveWaves(home)) {
|
|
54
|
+
let live;
|
|
55
|
+
try {
|
|
56
|
+
live = RunStore.find(w.cwd, w.wave)?.lots().filter((l) => !lotFinished(l)) ?? [];
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue; // état de vague illisible : la vague ne tient aucune ressource connue
|
|
60
|
+
}
|
|
61
|
+
held = [...held, ...live];
|
|
62
|
+
}
|
|
63
|
+
for (const s of held) {
|
|
64
|
+
running.add(lotAt(repoOf(s.repo), s.lot));
|
|
65
|
+
try {
|
|
66
|
+
for (const r of plansOf(s.repo).find((l) => l.id === s.lot)?.uses ?? [])
|
|
67
|
+
resources.add(r);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// plan illisible : aucune ressource connue
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { resources, running };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pour chaque dossier de projet de `dirs`, ses lots prêts (comme `nextUp`, hors « à décider » et hors lots d'une vague vivante) dont aucune ressource
|
|
77
|
+
* n'est prise par un lot `doing` d'un des projets (ou de ceux qui partagent leurs ressources, voir `sharingProjects`) ni par une vague vivante de `home` ni par les lots `held` d'une vague qui part. Un lot sans `uses:` n'en tient aucune : il est libre.
|
|
78
|
+
* Les projets sans lot libre n'ont pas d'entrée (clé : le dossier) ; un plan illisible est sauté.
|
|
79
|
+
*/
|
|
80
|
+
export function readyFree(dirs, home, held = []) {
|
|
81
|
+
const { resources, running } = taken(dirs, home, held);
|
|
82
|
+
const out = new Map();
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
let ready;
|
|
85
|
+
try {
|
|
86
|
+
ready = nextUp(plansOf(dir)).ready;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const repo = repoOf(dir);
|
|
92
|
+
const free = ready
|
|
93
|
+
.filter((l) => !decisionPending(l) && !running.has(lotAt(repo, l.id)) && !(l.uses ?? []).some((r) => resources.has(r)))
|
|
94
|
+
.map((l) => ({ id: l.id, title: l.title, ...(l.uses?.length ? { uses: l.uses } : {}) }));
|
|
95
|
+
if (free.length)
|
|
96
|
+
out.set(dir, free);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** Lots listés par projet dans la ligne ; au-delà, « projet +K » (le --json donne tout). */
|
|
101
|
+
const FREE_SHOWN = 3;
|
|
102
|
+
/** « prêts, ressources libres : projet:L3 titre (banc) · … », au plus FREE_SHOWN lots par projet puis « projet +K », ou null quand aucun lot prêt n'est libre. */
|
|
103
|
+
export function freeLine(entries, titleMax) {
|
|
104
|
+
const items = entries.flatMap((e) => [
|
|
105
|
+
...e.free.slice(0, FREE_SHOWN).map((l) => `${e.project}:${l.id} ${l.title.length <= titleMax ? l.title : `${l.title.slice(0, titleMax - 1)}…`}${l.uses?.length ? ` (${l.uses.join(', ')})` : ''}`),
|
|
106
|
+
...(e.free.length > FREE_SHOWN ? [`${e.project} +${e.free.length - FREE_SHOWN}`] : []),
|
|
107
|
+
]);
|
|
108
|
+
return items.length ? `prêts, ressources libres : ${items.join(' · ')}` : null;
|
|
109
|
+
}
|
package/dist/ref/add.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
import { attachRegions, citedPages, indexText } from './figures.js';
|
|
6
|
+
import { DPI, extractImages, imageFile, imageRegions, listImages, pageCount, renderPages, renderThumbnails, textLayout } from './pdf.js';
|
|
7
|
+
import { readDocument, writeDocument } from './store.js';
|
|
8
|
+
import { requireTools } from './tools.js';
|
|
9
|
+
/** Sans `--sections`, un document de cette taille ou plus petit est rendu en entier ; au-delà, seul l'index est construit. */
|
|
10
|
+
export const WHOLE_DOCUMENT_MAX = 30;
|
|
11
|
+
/** Une page de moins de 20 caractères de texte est tenue pour scannée (recette du skill norme-etude). */
|
|
12
|
+
const SCANNED_MAX_CHARS = 20;
|
|
13
|
+
/** [1, 2, 3, 6] → « 1-3, 6 ». */
|
|
14
|
+
function ranges(pages) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (let i = 0; i < pages.length;) {
|
|
17
|
+
let j = i;
|
|
18
|
+
while (j + 1 < pages.length && pages[j + 1] === pages[j] + 1)
|
|
19
|
+
j++;
|
|
20
|
+
out.push(j > i ? `${pages[i]}-${pages[j]}` : String(pages[i]));
|
|
21
|
+
i = j + 1;
|
|
22
|
+
}
|
|
23
|
+
return out.join(', ');
|
|
24
|
+
}
|
|
25
|
+
/** `3-5,9` → [3, 4, 5, 9] (pages du PDF, 1 = première), sans doublon, dans l'ordre. */
|
|
26
|
+
export function parseSections(text, pages) {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const part of text.split(',')) {
|
|
29
|
+
const m = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(part);
|
|
30
|
+
if (!m)
|
|
31
|
+
throw new RafError(`ref : --sections invalide : « ${part.trim()} » (attendu : 3, 3-5 ou 3-5,9)`);
|
|
32
|
+
const from = Number(m[1]);
|
|
33
|
+
const to = m[2] === undefined ? from : Number(m[2]);
|
|
34
|
+
if (from < 1 || to < from)
|
|
35
|
+
throw new RafError(`ref : --sections invalide : « ${part.trim()} » (pages de 1 à ${pages}, début avant fin)`);
|
|
36
|
+
if (to > pages)
|
|
37
|
+
throw new RafError(`ref : --sections : page ${to} demandée, le document a ${pages} pages`);
|
|
38
|
+
for (let n = from; n <= to; n++)
|
|
39
|
+
out.add(n);
|
|
40
|
+
}
|
|
41
|
+
return [...out].sort((a, b) => a - b);
|
|
42
|
+
}
|
|
43
|
+
const slugOf = (file) => basename(file, extname(file)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
44
|
+
export const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
45
|
+
/** `cadence ref add` : enregistre un PDF, rend les pages visées, garde le texte positionné et les images, construit l'index des figures. */
|
|
46
|
+
export function add(o) {
|
|
47
|
+
// Les outils d'abord : sans eux, rien n'est lu ni écrit.
|
|
48
|
+
const tools = requireTools(o.env);
|
|
49
|
+
const file = resolve(o.cwd, o.document);
|
|
50
|
+
if (!existsSync(file) || !statSync(file).isFile())
|
|
51
|
+
throw new RafError(`ref : document introuvable : ${file}`);
|
|
52
|
+
const slug = o.slug ?? slugOf(file);
|
|
53
|
+
if (!SLUG.test(slug))
|
|
54
|
+
throw new RafError(`ref : slug invalide : « ${slug} » (lettres minuscules, chiffres et tirets ; --slug pour le choisir)`);
|
|
55
|
+
const total = pageCount(tools, file);
|
|
56
|
+
const wanted = o.sections === undefined ? (total <= WHOLE_DOCUMENT_MAX ? Array.from({ length: total }, (_, i) => i + 1) : []) : parseSections(o.sections, total);
|
|
57
|
+
const sha256 = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
58
|
+
const docDir = join(o.refsDir(), slug);
|
|
59
|
+
const before = readDocument(docDir);
|
|
60
|
+
if (before && before.sha256 !== sha256) {
|
|
61
|
+
throw new RafError(`ref : le slug « ${slug} » est déjà pris par un autre document (${before.source}, empreinte ${before.sha256.slice(0, 12)}) : choisir un autre nom avec --slug`);
|
|
62
|
+
}
|
|
63
|
+
const text = textLayout(tools, file);
|
|
64
|
+
const listed = listImages(tools, file);
|
|
65
|
+
const index = indexText(text, new Set(listed.map((i) => i.page)));
|
|
66
|
+
// pages à rendre : celles visées, celles déjà rendues par un appel précédent du même document, et celles des figures qu'elles citent
|
|
67
|
+
const keep = new Set(before?.pages.filter((p) => p.rendered).map((p) => p.n));
|
|
68
|
+
const visees = new Set([...wanted, ...keep]);
|
|
69
|
+
const rendered = new Set([...visees, ...citedPages(index, visees)]);
|
|
70
|
+
const ordered = [...rendered].sort((a, b) => a - b);
|
|
71
|
+
renderPages(tools, file, ordered, join(docDir, 'pages'));
|
|
72
|
+
renderThumbnails(tools, file, total, join(docDir, 'thumbs'));
|
|
73
|
+
const images = [];
|
|
74
|
+
const placed = [];
|
|
75
|
+
const regions = new Map();
|
|
76
|
+
for (const n of ordered) {
|
|
77
|
+
if (!listed.some((i) => i.page === n))
|
|
78
|
+
continue;
|
|
79
|
+
extractImages(tools, file, n, join(docDir, 'images'));
|
|
80
|
+
regions.set(n, imageRegions(tools, file, n));
|
|
81
|
+
}
|
|
82
|
+
for (const img of listed) {
|
|
83
|
+
const box = regions.get(img.page)?.[img.rank - 1];
|
|
84
|
+
const stored = box && rendered.has(img.page) ? { page: img.page, file: `images/${imageFile(img.page, img.slot)}`, x: box.x, y: box.y, l: box.l, h: box.h } : { page: img.page, file: null, x: null, y: null, l: null, h: null };
|
|
85
|
+
images.push(stored);
|
|
86
|
+
placed.push({ page: img.page, box: stored.x === null ? null : { x: stored.x, y: stored.y, l: stored.l, h: stored.h } });
|
|
87
|
+
}
|
|
88
|
+
attachRegions(index, placed);
|
|
89
|
+
const pages = text.map((p) => ({ ...p, rendered: rendered.has(p.n) }));
|
|
90
|
+
const doc = {
|
|
91
|
+
slug,
|
|
92
|
+
source: file,
|
|
93
|
+
sha256,
|
|
94
|
+
added: before?.added ?? o.now.toISOString(),
|
|
95
|
+
dpi: DPI,
|
|
96
|
+
sections: o.sections ?? before?.sections ?? null,
|
|
97
|
+
tools,
|
|
98
|
+
pages,
|
|
99
|
+
images,
|
|
100
|
+
figures: index.figures,
|
|
101
|
+
unresolved: index.unresolved,
|
|
102
|
+
};
|
|
103
|
+
writeDocument(docDir, doc);
|
|
104
|
+
o.out(`${slug} : ${basename(file)}`);
|
|
105
|
+
o.out(`pages : ${total}`);
|
|
106
|
+
const sansTexte = text.filter((p) => p.lines.reduce((n, l) => n + l.text.trim().length, 0) < SCANNED_MAX_CHARS).map((p) => p.n);
|
|
107
|
+
if (sansTexte.length > 0)
|
|
108
|
+
o.out(`pages sans texte : ${sansTexte.length} (${ranges(sansTexte)}) — scannées ou faites d'images : l'index n'en dit rien, parcourir ${join(docDir, 'thumbs')} pour choisir les pages à rendre (--sections)`);
|
|
109
|
+
o.out(`rendues : ${ordered.length}${ordered.length > 0 ? ` (${ordered.join(', ')})` : ''}`);
|
|
110
|
+
o.out(`images : ${images.length}`);
|
|
111
|
+
o.out(`figures : ${index.figures.length}`);
|
|
112
|
+
o.out(`liens non résolus : ${index.unresolved.length}`);
|
|
113
|
+
o.out(`dossier : ${docDir}`);
|
|
114
|
+
if (ordered.length === 0)
|
|
115
|
+
o.out(`aucune page rendue (grand document : seul l'index est construit) — cadence ref add ${o.document} --slug ${slug} --sections 3-5 pour rendre des pages`);
|
|
116
|
+
}
|