@sylad/cadence 0.2.0 → 0.6.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/dist/deliver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { parse } from 'yaml';
3
- import { headSha, isAncestor, onRemote, readCommits, repoStatus } from './git.js';
4
- import { extractRefs, RafError } from './plan.js';
3
+ import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
4
+ import { RafError } from './plan.js';
5
5
  import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
6
6
  const POLL_CI = 15_000;
7
7
  const CI_APPEAR = 300_000;
@@ -23,6 +23,14 @@ export function parseDeliverConfig(text, file) {
23
23
  throw new RafError(`${file} : clé deliver absente`);
24
24
  const cfg = d;
25
25
  const bad = (what) => new RafError(`${file} : deliver.${what}`);
26
+ let script;
27
+ if (cfg.script !== undefined) {
28
+ if (typeof cfg.script !== 'string' || !cfg.script.trim())
29
+ throw bad('script : commande non vide attendue');
30
+ if (cfg.ci !== undefined || cfg.deploy !== undefined)
31
+ throw bad('script remplace ci et deploy : garder l\'un ou les autres');
32
+ script = cfg.script.trim();
33
+ }
26
34
  let ci = 'none';
27
35
  if (cfg.ci === 'github' || cfg.ci === 'none')
28
36
  ci = cfg.ci;
@@ -33,6 +41,8 @@ export function parseDeliverConfig(text, file) {
33
41
  }
34
42
  else if (cfg.ci !== undefined)
35
43
  throw bad('ci : attendu github, none ou { command: "…" }');
44
+ if (cfg.allowDirty !== undefined && typeof cfg.allowDirty !== 'boolean')
45
+ throw bad('allowDirty : true ou false attendu');
36
46
  const seconds = (key, dflt) => {
37
47
  const v = cfg[key];
38
48
  if (v === undefined)
@@ -45,7 +55,8 @@ export function parseDeliverConfig(text, file) {
45
55
  if (!Array.isArray(deploy) || !deploy.every((c) => typeof c === 'string' && c.trim()))
46
56
  throw bad('deploy : liste de commandes non vides attendue');
47
57
  const verify = cfg.verify ?? [];
48
- if (!Array.isArray(verify) || verify.length === 0) {
58
+ // Avec un script de projet, les contrôles métier sont les siens : ceux de cadence deviennent facultatifs.
59
+ if (!Array.isArray(verify) || (verify.length === 0 && script === undefined)) {
49
60
  throw bad('verify : au moins une vérification (une livraison se prouve par son effet)');
50
61
  }
51
62
  const checks = verify.map((v, i) => {
@@ -68,7 +79,16 @@ export function parseDeliverConfig(text, file) {
68
79
  throw bad(`${where}.contains : texte attendu`);
69
80
  return { url, ...(status === undefined ? {} : { status: status }), ...(contains === undefined ? {} : { contains }) };
70
81
  });
71
- return { ci, ciTimeout: seconds('ciTimeout', 1800), deploy: deploy, verify: checks, verifyTimeout: seconds('verifyTimeout', 300), deployTimeout: seconds('deployTimeout', 1800) };
82
+ return {
83
+ ci,
84
+ ...(script === undefined ? {} : { script }),
85
+ ...(cfg.allowDirty ? { allowDirty: true } : {}),
86
+ ciTimeout: seconds('ciTimeout', 1800),
87
+ deploy: deploy,
88
+ verify: checks,
89
+ verifyTimeout: seconds('verifyTimeout', 300),
90
+ deployTimeout: seconds('deployTimeout', 1800),
91
+ };
72
92
  }
73
93
  /** Dépendances réelles : sh, gh, fetch, horloge. */
74
94
  export function realDeps(root) {
@@ -119,6 +139,10 @@ export function realDeps(root) {
119
139
  function substitute(text, sha) {
120
140
  return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
121
141
  }
142
+ /** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
143
+ function shellQuote(arg) {
144
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
145
+ }
122
146
  function describeCheck(c, sha) {
123
147
  if (c.command !== undefined)
124
148
  return c.command;
@@ -131,12 +155,19 @@ export async function deliver(ctx, deps) {
131
155
  err(`deliver : ${msg}`);
132
156
  return 2;
133
157
  };
134
- const sha = headSha(ctx.root);
158
+ const head0 = headSha(ctx.root);
159
+ const sha = ctx.sha === undefined ? head0 : resolveCommit(ctx.root, ctx.sha);
135
160
  if (!sha)
136
- return refuse('aucun commit à livrer');
161
+ return refuse(ctx.sha === undefined ? 'aucun commit à livrer' : `--sha ${ctx.sha} : commit introuvable`);
162
+ if (ctx.args.length && config.script === undefined) {
163
+ return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
164
+ }
165
+ const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
137
166
  const repo = repoStatus(ctx.root);
138
- if (repo.dirty)
167
+ if (repo.dirty && !config.allowDirty)
139
168
  return refuse(`${repo.dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
169
+ if (repo.dirty)
170
+ err(`deliver : ${repo.dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
140
171
  if (!onRemote(ctx.root, sha))
141
172
  return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
142
173
  const lock = readLock(ctx.state);
@@ -149,16 +180,25 @@ export async function deliver(ctx, deps) {
149
180
  if (why)
150
181
  return refuse(`ci: github exige gh authentifié — ${why}`);
151
182
  }
152
- const branch = repo.branch ?? 'HEAD détachée';
153
- const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: repo.branch ?? '' };
183
+ // Un sha choisi n'est pas forcément sur la branche courante : ne pas lui en prêter une.
184
+ const atHead = sha === head0;
185
+ const branch = atHead ? (repo.branch ?? 'HEAD détachée') : 'sha choisi, antérieur ou étranger à la tête';
186
+ const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: atHead ? (repo.branch ?? '') : '' };
154
187
  if (ctx.dryRun) {
155
188
  out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
156
- out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
157
- out(' Déploiement :');
158
- if (config.deploy.length === 0)
159
- out(' (aucune commande)');
160
- config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
161
- out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
189
+ if (script !== null) {
190
+ out(` Script du projet : ${script}`);
191
+ out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
192
+ }
193
+ else {
194
+ out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
195
+ out(' Déploiement :');
196
+ if (config.deploy.length === 0)
197
+ out(' (aucune commande)');
198
+ config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
199
+ }
200
+ if (config.verify.length)
201
+ out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
162
202
  config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
163
203
  out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
164
204
  return 0;
@@ -175,21 +215,37 @@ export async function deliver(ctx, deps) {
175
215
  return 1;
176
216
  };
177
217
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
178
- const ci = await waitCi(ctx, deps, sha, env);
179
- if (ci)
180
- return fail(ci);
181
- for (const [i, cmd] of config.deploy.entries()) {
182
- out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
183
- const code = deps.exec(cmd, env, config.deployTimeout * 1000);
218
+ if (script !== null) {
219
+ out(`→ script du projet : ${script}`);
220
+ const code = deps.exec(script, env, config.deployTimeout * 1000);
184
221
  if (code !== 0)
185
- return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
222
+ return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
223
+ }
224
+ else {
225
+ const ci = await waitCi(ctx, deps, sha, env);
226
+ if (ci)
227
+ return fail(ci);
228
+ for (const [i, cmd] of config.deploy.entries()) {
229
+ out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
230
+ const code = deps.exec(cmd, env, config.deployTimeout * 1000);
231
+ if (code !== 0)
232
+ return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
233
+ }
186
234
  }
187
235
  const failed = await verifyAll(ctx, deps, sha, env);
188
236
  if (failed)
189
237
  return fail(failed);
190
- const delivered = deliveredLots(ctx, lastDelivery(ctx.state), sha);
191
- appendDelivery(ctx.state, ctx.today, sha);
192
- out(`✓ livré et vérifié : ${env.CADENCE_SHORT}`);
238
+ // Un script de projet peut commiter pendant la livraison (horodatage d'une entrée Nouveautés) :
239
+ // ce qu'il a livré est alors la nouvelle tête, pas le sha de départ.
240
+ const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
241
+ const moved = head !== sha && isAncestor(ctx.root, sha, head);
242
+ const final = moved ? head : sha;
243
+ const delivered = deliveredLots(ctx, lastDelivery(ctx.state), final);
244
+ appendDelivery(ctx.state, ctx.today, final);
245
+ if (moved)
246
+ out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
247
+ else
248
+ out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
193
249
  if (delivered)
194
250
  out(delivered);
195
251
  return 0;
@@ -278,9 +334,11 @@ async function verifyAll(ctx, deps, sha, env) {
278
334
  const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
279
335
  if (reason === null)
280
336
  break;
281
- if (deps.now() >= deadline)
337
+ const left = deadline - deps.now();
338
+ if (left <= 0)
282
339
  return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
283
- await deps.sleep(POLL_VERIFY);
340
+ // jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
341
+ await deps.sleep(Math.min(POLL_VERIFY, left));
284
342
  }
285
343
  }
286
344
  return null;
@@ -295,11 +353,11 @@ function deliveredLots(ctx, prev, sha) {
295
353
  const known = new Set(ctx.plan.lots().map((l) => l.id));
296
354
  const ids = new Set();
297
355
  for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
298
- for (const r of extractRefs(`${c.subject}\n${c.body}`, ctx.plan.prefix))
356
+ for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
299
357
  if (known.has(r.lot))
300
358
  ids.add(r.lot);
301
359
  }
302
360
  if (ids.size === 0)
303
361
  return null;
304
- return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — raf done si l'effet est celui attendu`;
362
+ return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
305
363
  }
package/dist/git.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { basename, dirname, resolve } from 'node:path';
3
3
  function git(cwd, args) {
4
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
4
+ // Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
5
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024 });
5
6
  }
6
7
  export function gitRoot(cwd) {
7
8
  try {
@@ -60,6 +61,10 @@ function tryGit(cwd, args) {
60
61
  export function headSha(cwd) {
61
62
  return tryGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD']);
62
63
  }
64
+ /** Sha complet du commit que désigne `rev` (sha abrégé, branche, tag), null s'il n'existe pas. */
65
+ export function resolveCommit(cwd, rev) {
66
+ return tryGit(cwd, ['rev-parse', '--verify', '-q', `${rev}^{commit}`]);
67
+ }
63
68
  export function repoStatus(cwd) {
64
69
  const lines = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean);
65
70
  const untracked = lines.filter((l) => l.startsWith('??')).length;
@@ -89,3 +94,31 @@ export function gitCommonDir(cwd) {
89
94
  export function isAncestor(cwd, a, b) {
90
95
  return tryGit(cwd, ['merge-base', '--is-ancestor', a, b]) !== null;
91
96
  }
97
+ /**
98
+ * Instant (ms), date d'auteur (conservée par rebase, amend, cherry-pick), du commit qui a ajouté chaque fichier
99
+ * de `dir` SOUS CE NOM (--no-renames : un renommage compte comme un ajout, l'historique antérieur n'est pas suivi),
100
+ * clé = chemin relatif à `dir`.
101
+ * Vide hors d'un dépôt git ; un fichier jamais commité en est absent.
102
+ */
103
+ export function addedTimes(dir) {
104
+ const times = new Map();
105
+ let out;
106
+ try {
107
+ // quotepath=off + -z : noms accentués tels quels, sans guillemets ni échappement octal.
108
+ out = git(dir, ['-c', 'core.quotepath=off', 'log', '-z', '--diff-filter=A', '--no-renames', '--relative', '--name-only', `--format=${RECORD}%aI`, '--', '.']);
109
+ }
110
+ catch {
111
+ return times;
112
+ }
113
+ // Enregistrement = RECORD date \0 \n nom \0 nom \0 … ; le plus ancien ajout l'emporte.
114
+ for (const record of out.split(RECORD).filter(Boolean)) {
115
+ const [stamp, ...files] = record.split('\0');
116
+ const t = Date.parse(stamp);
117
+ for (const [i, f] of files.entries()) {
118
+ const name = i === 0 ? f.replace(/^\n/, '') : f;
119
+ if (name)
120
+ times.set(name, Math.min(t, times.get(name) ?? Infinity));
121
+ }
122
+ }
123
+ return times;
124
+ }
package/dist/link.js CHANGED
@@ -1,15 +1,15 @@
1
- import { extractRefs } from './plan.js';
2
1
  export function isMerge(c) {
3
2
  return /^Merge\b/.test(c.subject);
4
3
  }
5
- export function linkCommits(lots, commits, prefix) {
4
+ /** `refs` lit les références d'un message : `plan.refs`. */
5
+ export function linkCommits(lots, commits, refsOf) {
6
6
  const ids = new Set(lots.map((l) => l.id));
7
7
  const tasks = new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`)));
8
8
  const byLot = new Map();
9
9
  const orphans = [];
10
10
  const unknown = [];
11
11
  for (const c of commits) {
12
- const refs = extractRefs(`${c.subject}\n${c.body}`, prefix);
12
+ const refs = refsOf(`${c.subject}\n${c.body}`);
13
13
  if (refs.length === 0) {
14
14
  if (!isMerge(c))
15
15
  orphans.push(c);
package/dist/news.js CHANGED
@@ -1,10 +1,20 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { parse, stringify } from 'yaml';
4
- import { isDay } from './dates.js';
4
+ import { isDay, toStamp } from './dates.js';
5
+ import { addedTimes } from './git.js';
5
6
  import { RafError } from './plan.js';
7
+ const CREATED_EMPTY = 'created vide — cadence news stamp';
8
+ const CREATED = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?$/;
9
+ const OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
6
10
  const CAPTURE = /^[\w./-]+\.(?:png|jpe?g|webp|gif)$/i;
7
11
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
12
+ /** Bien formée ET réelle : Date.parse accepte le 30 février (et rend NaN pour 25:70). */
13
+ function validStamp(s) {
14
+ const [y, mo, d, h, mi, sec = 0] = s.match(/\d+/g).slice(0, 6).map(Number);
15
+ const day = new Date(Date.UTC(y, mo - 1, d));
16
+ return (!Number.isNaN(Date.parse(s.replace(' ', 'T'))) && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && sec < 60);
17
+ }
8
18
  const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
9
19
  export function parseEntry(file, text) {
10
20
  const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], body: '', problems: [] };
@@ -23,6 +33,20 @@ export function parseEntry(file, text) {
23
33
  }
24
34
  entry.title = String(head.title ?? '').trim();
25
35
  entry.date = String(head.date ?? '');
36
+ if ('created' in head) {
37
+ const created = String(head.created ?? '').trim();
38
+ if (!created)
39
+ entry.problems.push(CREATED_EMPTY);
40
+ else if (!CREATED.test(created))
41
+ entry.problems.push(`created « ${created} » n'est pas une heure AAAA-MM-JJTHH:MM±hh:mm`);
42
+ // Sans fuseau, l'heure serait lue dans celui de la machine : l'ordre changerait entre le poste et la CI.
43
+ else if (!OFFSET.test(created))
44
+ entry.problems.push(`created « ${created} » sans fuseau : ajouter ±hh:mm ou Z`);
45
+ else if (!validStamp(created))
46
+ entry.problems.push(`created « ${created} » n'est pas une heure valide`);
47
+ else
48
+ entry.created = created.replace(' ', 'T');
49
+ }
26
50
  entry.lots = list(head.lots);
27
51
  entry.captures = list(head.captures);
28
52
  if (head.nocapture != null && String(head.nocapture).trim())
@@ -42,14 +66,29 @@ export function parseEntry(file, text) {
42
66
  entry.problems.push(`date « ${entry.date} » n'est pas une date AAAA-MM-JJ`);
43
67
  return entry;
44
68
  }
45
- /** Entrées du dossier, plus récentes d'abord (à date égale, par nom de fichier décroissant). */
46
- export function loadEntries(dir) {
69
+ /**
70
+ * Entrées du dossier, plus récentes d'abord : par date, puis à date égale par instant de création
71
+ * (`created` de l'en-tête, sinon date d'auteur du commit qui a ajouté le fichier sous ce nom — un renommage
72
+ * compte comme un ajout — ; pas encore commité = le plus récent), puis par nom de fichier décroissant.
73
+ * `added` (historique git) n'est appelé que si une entrée n'a pas de `created`.
74
+ */
75
+ export function loadEntries(dir, added = () => addedTimes(dir)) {
47
76
  if (!existsSync(dir))
48
77
  return [];
49
- return readdirSync(dir, { withFileTypes: true })
78
+ const entries = readdirSync(dir, { withFileTypes: true })
50
79
  .filter((f) => f.isFile() && f.name.endsWith('.md') && f.name.toLowerCase() !== 'readme.md')
51
- .map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')))
52
- .sort((a, b) => (a.date === b.date ? b.file.localeCompare(a.file) : b.date.localeCompare(a.date)));
80
+ .map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')));
81
+ const history = entries.some((e) => !e.created) ? added() : new Map();
82
+ const created = (e) => (e.created ? Date.parse(e.created) : history.get(e.file) ?? Infinity);
83
+ return entries.sort((a, b) => {
84
+ if (a.date !== b.date)
85
+ return b.date.localeCompare(a.date);
86
+ const ta = created(a);
87
+ const tb = created(b);
88
+ if (ta !== tb)
89
+ return ta < tb ? 1 : -1;
90
+ return b.file.localeCompare(a.file);
91
+ });
53
92
  }
54
93
  export function newsIssues(lots, entries, dir) {
55
94
  const issues = [];
@@ -66,6 +105,9 @@ export function newsIssues(lots, entries, dir) {
66
105
  if (!existsSync(path) || !statSync(path).isFile())
67
106
  issues.push({ kind: 'missing-capture', message: `${e.file} : capture absente ${c}` });
68
107
  }
108
+ if (e.problems.length === 0 && !e.created) {
109
+ issues.push({ kind: 'no-time', message: `${e.file} sans heure de création (created: AAAA-MM-JJTHH:MM±hh:mm) — cadence news stamp` });
110
+ }
69
111
  if (e.problems.length === 0 && e.captures.length === 0 && !e.nocapture) {
70
112
  issues.push({ kind: 'no-capture', message: `${e.file} sans capture (ou « nocapture: raison »)` });
71
113
  }
@@ -87,16 +129,47 @@ export function slugify(title) {
87
129
  .slice(0, 60)
88
130
  .replace(/-+$/, '') || 'entree');
89
131
  }
90
- /** Crée le squelette d'une entrée ; refuse d'écraser un fichier existant. */
91
- export function newEntry(dir, lots, rawTitle, today) {
132
+ /** Crée le squelette d'une entrée, horodatée de `now` ; refuse d'écraser un fichier existant. */
133
+ export function newEntry(dir, lots, rawTitle, today, now) {
92
134
  const title = rawTitle.replace(/\s+/g, ' ').trim(); // un saut de ligne casserait l'en-tête
93
135
  const path = join(dir, `${today}-${slugify(title)}.md`);
94
136
  if (existsSync(path))
95
137
  throw new RafError(`${path} existe déjà`);
96
138
  mkdirSync(dir, { recursive: true });
97
- writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
139
+ writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
98
140
  return path;
99
141
  }
142
+ /**
143
+ * Migration : écrit `created:` dans les entrées lisibles qui n'en ont pas (ou l'ont vide), d'après la date
144
+ * d'auteur du commit qui a ajouté le fichier sous ce nom — un renommage n'est pas suivi — (`now` s'il n'est pas
145
+ * encore commité). Le reste du fichier est laissé tel quel.
146
+ */
147
+ export function stampEntries(dir, now) {
148
+ let history;
149
+ const added = () => (history ??= addedTimes(dir)); // un seul git log, et seulement s'il sert
150
+ const done = [];
151
+ for (const e of loadEntries(dir, added)) {
152
+ // Seule une clé created vide est réparée ici ; une heure fausse reste à corriger à la main.
153
+ if (e.created || !e.problems.every((p) => p === CREATED_EMPTY))
154
+ continue;
155
+ const created = toStamp(new Date(added().get(e.file) ?? now.getTime()));
156
+ const path = join(dir, e.file);
157
+ const text = readFileSync(path, 'utf8');
158
+ const lines = text.split('\n');
159
+ const end = lines.findIndex((l, i) => i > 0 && l.trimEnd() === '---');
160
+ const key = (name) => lines.findIndex((l, i) => i > 0 && i < end && new RegExp(`^${name}\\s*:`).test(l));
161
+ const existing = key('created');
162
+ const dateLine = key('date');
163
+ const eol = lines[dateLine >= 0 ? dateLine : 0].endsWith('\r') ? '\r' : '';
164
+ if (existing >= 0)
165
+ lines[existing] = `created: ${created}${eol}`; // clé vide : remplacée, jamais dupliquée
166
+ else
167
+ lines.splice(dateLine >= 0 ? dateLine + 1 : end, 0, `created: ${created}${eol}`);
168
+ writeFileSync(path, lines.join('\n'));
169
+ done.push({ file: e.file, created });
170
+ }
171
+ return done;
172
+ }
100
173
  function escapeText(s) {
101
174
  return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
102
175
  }