@plannotator/pi-extension 0.28.50 → 0.28.51

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.
@@ -46,8 +46,10 @@ import {
46
46
  shapeNoteId,
47
47
  shapeNoteWaits,
48
48
  shapeParts,
49
+ shapeViewNow,
49
50
  type ShapeBoard,
50
51
  type ShapeNote,
52
+ type ShapeProject,
51
53
  type ShapeRecord,
52
54
  type ShapeSession,
53
55
  type ShapeState,
@@ -65,7 +67,7 @@ const MESSAGE_ID_RE = /^msg_[0-9A-HJKMNP-TV-Z]{26}$/;
65
67
  export interface ShapeSummary {
66
68
  id: string;
67
69
  title: string;
68
- project: { name: string; root: string };
70
+ project: ShapeProject;
69
71
  thread: string | null;
70
72
  state: ShapeState;
71
73
  updated: string;
@@ -92,7 +94,7 @@ export interface ShapeUpdateInput {
92
94
  thread?: string | null;
93
95
  /** The last thread message this version took in. */
94
96
  through?: string | null;
95
- project: { name: string; root: string };
97
+ project: ShapeProject;
96
98
  session?: ShapeSession | null;
97
99
  /** The folder the board file is in: image references by relative path are read from here. */
98
100
  sourceDir?: string | null;
@@ -263,6 +265,25 @@ export function shapesDirOf(dataDir: string): string {
263
265
  return join(dataDir, "shapes");
264
266
  }
265
267
 
268
+ export type { ShapeProject };
269
+
270
+ /** A linked worktree's `.git` is a file saying `gitdir: <path>`, and that path exists; a main checkout's is a folder. */
271
+ export function isLinkedWorktree(folder: string): boolean {
272
+ try {
273
+ const dotGit = join(folder, ".git");
274
+ if (!statSync(dotGit).isFile()) return false;
275
+ const gitdir = /^gitdir: (.+)$/.exec(readFileSync(dotGit, "utf8").trim())?.[1];
276
+ return !!gitdir && existsSync(resolve(folder, gitdir));
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /** The checkout a board's code lives in: the linked worktree of its latest write while that is still one, else the project's root. */
283
+ export function shapeCodeRoot(project: ShapeProject): string {
284
+ return project.worktree && isLinkedWorktree(project.worktree) ? project.worktree : project.root;
285
+ }
286
+
266
287
  export function createShapeStore(options: { dataDir: string; now?: () => number }): ShapeStore {
267
288
  const dir = shapesDirOf(options.dataDir);
268
289
  const now = options.now ?? (() => Date.now());
@@ -287,68 +308,128 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
287
308
  }
288
309
 
289
310
  const recordOf = (folder: string) => readJson<ShapeRecord>(join(folder, "shape.json"));
290
- const versionsOf = (folder: string) => readLines<ShapeVersion>(join(folder, "versions.jsonl"));
311
+ // A version or note saved when What to know was Requirements says "reqs": read it as the view it is now. The file stays as written.
312
+ const nowKnow = <T extends { view: unknown }>(x: T): T => (shapeViewNow(x.view) === x.view ? x : { ...x, view: "know" });
313
+ const versionsOf = (folder: string) => readLines<ShapeVersion>(join(folder, "versions.jsonl")).map((v) => (v.parts ? { ...v, parts: v.parts.map(nowKnow) } : v));
291
314
 
292
315
  function notesOf(folder: string): ShapeNote[] {
293
316
  const byId = new Map<string, ShapeNote>();
294
317
  for (const note of readLines<ShapeNote>(join(folder, "chat.jsonl"))) {
295
- if (note && typeof note.id === "string") byId.set(note.id, note);
318
+ if (note && typeof note.id === "string") byId.set(note.id, nowKnow(note));
296
319
  }
297
320
  return [...byId.values()];
298
321
  }
299
322
 
300
323
  const drawingNow = (record: ShapeRecord) => !!record.drawing && Date.parse(record.drawing.until) > now();
301
324
 
325
+ /** Where an image comes from and its name in files/. Null: it stays as written (a files/ name, a data:image) or an error was recorded. */
326
+ function imageFile(src: string, field: string, sourceDir: string | null | undefined, errors: string[]): { path: string; name: string } | null {
327
+ // files/… is a picture the board keeps, unless the board's own folder really has one by that name: that one is copied.
328
+ const local = src.startsWith("files/") && sourceDir ? resolve(sourceDir, src) : null;
329
+ if (src.startsWith("files/") && !(local && existsSync(local))) {
330
+ if (!/^files\/[^/\\%?#.][^/\\%?#]*$/.test(src)) errors.push(`${field}: "${src.slice(0, 80)}" is not one file under files/`);
331
+ return null;
332
+ }
333
+ if (/^data:image\/(?:png|jpeg|gif|webp)[;,]/i.test(src)) return null;
334
+ // A board loads nothing from outside: an image is a file on this computer, copied in.
335
+ if (/^[a-z][a-z0-9+.-]+:|^\/\/|^\\\\/i.test(src)) {
336
+ errors.push(`${field}: "${src.slice(0, 80)}" is a URL; an image must be a file on this computer (it is copied into the board)`);
337
+ return null;
338
+ }
339
+ const path = isAbsolute(src) ? src : sourceDir ? resolve(sourceDir, src) : null;
340
+ if (!path) {
341
+ errors.push(`${field}: a relative image path needs the board file's folder`);
342
+ return null;
343
+ }
344
+ if (!IMAGE_EXT.has(extname(path).toLowerCase())) {
345
+ errors.push(`${field}: "${src}" is not a PNG, JPEG, GIF or WebP image`);
346
+ return null;
347
+ }
348
+ let size: number;
349
+ let real: string;
350
+ try {
351
+ const stat = statSync(path);
352
+ if (!stat.isFile()) throw new Error("not a file");
353
+ size = stat.size;
354
+ real = realpathSync(path);
355
+ } catch {
356
+ errors.push(`${field}: "${src}" was not found`);
357
+ return null;
358
+ }
359
+ // A link named .png is what it points at.
360
+ if (!IMAGE_EXT.has(extname(real).toLowerCase())) {
361
+ errors.push(`${field}: "${src}" is not a PNG, JPEG, GIF or WebP image`);
362
+ return null;
363
+ }
364
+ if (size > SHAPE_IMAGE_MAX_BYTES) {
365
+ errors.push(`${field}: "${src}" is ${size} bytes; at most ${SHAPE_IMAGE_MAX_BYTES}`);
366
+ return null;
367
+ }
368
+ return { path, name: `${createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 10)}-${basename(path).replace(/[^\w.-]+/g, "_")}` };
369
+ }
370
+
371
+ function copyImage(file: { path: string; name: string }, folder: string): void {
372
+ const files = join(folder, "files");
373
+ ensureDir(files);
374
+ const dest = join(files, file.name);
375
+ if (!existsSync(dest)) copyFileSync(file.path, dest);
376
+ }
377
+
378
+ /**
379
+ * A picture a drawing names by its path (`<img src>`, SVG `<image href>`) gets
380
+ * its files/ name before the board is checked; the file is copied once the
381
+ * board's folder is known. A URL stays as written, for the check to refuse.
382
+ */
383
+ function drawnImages(input: unknown, sourceDir: string | null | undefined): { board: unknown; files: { path: string; name: string }[]; errors: string[] } {
384
+ const errors: string[] = [];
385
+ const files: { path: string; name: string }[] = [];
386
+ const views = (input as { views?: unknown } | null)?.views;
387
+ if (!views || typeof views !== "object") return { board: input, files, errors };
388
+ const inHtml = (html: unknown, field: string): unknown =>
389
+ typeof html !== "string"
390
+ ? html
391
+ // Attribute by attribute, so words inside another attribute's value (alt="x src=y") are left alone.
392
+ : html.replace(/(<(?:img|image)\b(?:\s+(?!(?:xlink:href|src|href)\s*=)[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*\s+)(xlink:href|src|href)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'>]+))/gi, (all, head: string, attr: string, q2?: string, q1?: string, bare?: string) => {
393
+ const src = (q2 ?? q1 ?? bare)!;
394
+ if (/^(?:#|[a-z][a-z0-9+.-]+:|\/\/|\\\\)/i.test(src)) return all;
395
+ const name = attr.toLowerCase() === "xlink:href" ? "href" : attr;
396
+ const file = imageFile(src, field, sourceDir, errors);
397
+ if (!file) return src.startsWith("files/") ? `${head}${name}="${src}"` : all;
398
+ files.push(file);
399
+ return `${head}${name}="files/${file.name}"`;
400
+ });
401
+ const next: Record<string, unknown> = {};
402
+ for (const [name, view] of Object.entries(views)) {
403
+ if (!view || typeof view !== "object") {
404
+ next[name] = view;
405
+ continue;
406
+ }
407
+ const v = view as { html?: unknown; tall?: { html?: unknown }; more?: { html?: unknown } };
408
+ next[name] = {
409
+ ...v,
410
+ ...("html" in v ? { html: inHtml(v.html, `views.${name}.html`) } : {}),
411
+ ...(v.tall ? { tall: { ...v.tall, html: inHtml(v.tall.html, `views.${name}.tall.html`) } } : {}),
412
+ ...(v.more ? { more: { ...v.more, html: inHtml(v.more.html, `views.${name}.more.html`) } } : {}),
413
+ };
414
+ }
415
+ return { board: { ...(input as object), views: next }, files, errors };
416
+ }
417
+
302
418
  /** Copy the images the refs cite into files/, and point the refs at the copies. */
303
419
  function keepImages(board: ShapeBoard, folder: string, sourceDir: string | null | undefined): { board: ShapeBoard; errors: string[] } {
304
420
  const errors: string[] = [];
305
421
  if (!board.refs?.length) return { board, errors };
306
- const files = join(folder, "files");
307
422
  const keep = (src: string, field: string): string => {
308
- if (src.startsWith("files/")) {
309
- if (!/^files\/[^/\\%?#.][^/\\%?#]*$/.test(src)) errors.push(`${field}: "${src.slice(0, 80)}" is not one file under files/`);
310
- return src;
311
- }
312
- if (/^data:image\/(?:png|jpeg|gif|webp)[;,]/i.test(src)) return src;
313
- // A board loads nothing from outside: an image is a file on this computer, copied in.
314
- if (/^[a-z][a-z0-9+.-]+:|^\/\/|^\\\\/i.test(src)) {
315
- errors.push(`${field}: "${src.slice(0, 80)}" is a URL; an image must be a file on this computer (it is copied into the board)`);
316
- return src;
317
- }
318
- const path = isAbsolute(src) ? src : sourceDir ? resolve(sourceDir, src) : null;
319
- if (!path) {
320
- errors.push(`${field}: a relative image path needs the board file's folder`);
321
- return src;
322
- }
323
- if (!IMAGE_EXT.has(extname(path).toLowerCase())) {
324
- errors.push(`${field}: "${src}" is not a PNG, JPEG, GIF or WebP image`);
325
- return src;
326
- }
327
- let size: number;
328
- try {
329
- const stat = statSync(path);
330
- if (!stat.isFile()) throw new Error("not a file");
331
- size = stat.size;
332
- } catch {
333
- errors.push(`${field}: "${src}" was not found`);
334
- return src;
335
- }
336
- if (size > SHAPE_IMAGE_MAX_BYTES) {
337
- errors.push(`${field}: "${src}" is ${size} bytes; at most ${SHAPE_IMAGE_MAX_BYTES}`);
338
- return src;
339
- }
340
- const bytes = readFileSync(path);
341
- const name = `${createHash("sha256").update(bytes).digest("hex").slice(0, 10)}-${basename(path).replace(/[^\w.-]+/g, "_")}`;
342
- ensureDir(files);
343
- const dest = join(files, name);
344
- if (!existsSync(dest)) copyFileSync(path, dest);
345
- return `files/${name}`;
423
+ const file = imageFile(src, field, sourceDir, errors);
424
+ if (!file) return src;
425
+ copyImage(file, folder);
426
+ return `files/${file.name}`;
346
427
  };
347
428
  const refs = board.refs.map((ref, i) => {
348
- if (ref.kind !== "image") return ref;
429
+ if (ref.kind !== "image" && !ref.images) return ref;
349
430
  return {
350
431
  ...ref,
351
- target: keep(ref.target, `refs[${i}].target`),
432
+ ...(ref.kind === "image" ? { target: keep(ref.target, `refs[${i}].target`) } : {}),
352
433
  ...(ref.images ? { images: ref.images.map((im, j) => ({ ...im, src: keep(im.src, `refs[${i}].images[${j}].src`) })) } : {}),
353
434
  };
354
435
  });
@@ -356,7 +437,9 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
356
437
  }
357
438
 
358
439
  function update(input: ShapeUpdateInput): ShapeUpdateResult {
359
- const checked = checkShapeBoard(input.board);
440
+ const drawn = drawnImages(input.board, input.sourceDir);
441
+ if (drawn.errors.length) return { ok: false, code: "invalid", errors: drawn.errors };
442
+ const checked = checkShapeBoard(drawn.board);
360
443
  if (!checked.ok) return { ok: false, code: "invalid", errors: checked.errors };
361
444
  const ids = (["thread", "through"] as const).filter((k) => input[k] != null && !MESSAGE_ID_RE.test(input[k]!));
362
445
  if (ids.length) return { ok: false, code: "invalid", errors: ids.map((k) => `${k}: must be an Inbox message id (msg_…)`) };
@@ -372,6 +455,7 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
372
455
  rmSync(folder, { recursive: true, force: true });
373
456
  return { ok: false, code: "invalid", errors: kept.errors };
374
457
  }
458
+ for (const file of drawn.files) copyImage(file, folder);
375
459
  const version: ShapeVersion = {
376
460
  ...kept.board,
377
461
  n: 1,
@@ -429,6 +513,7 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
429
513
  }
430
514
  const kept = keepImages(checked.value, folder, input.sourceDir);
431
515
  if (kept.errors.length) return { ok: false, code: "invalid", errors: kept.errors };
516
+ for (const file of drawn.files) copyImage(file, folder);
432
517
  const n = latest.n + 1;
433
518
  const version: ShapeVersion = {
434
519
  ...kept.board,
@@ -440,7 +525,9 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
440
525
  parts: shapeParts(kept.board),
441
526
  };
442
527
  appendLine(join(folder, "versions.jsonl"), version);
443
- const next: ShapeRecord = { ...record, title: kept.board.title, updated: at, latest: n, drawing: null };
528
+ // The board's checkout follows its latest write, within its project.
529
+ const project = input.project.root === record.project.root ? { ...input.project, name: record.project.name } : record.project;
530
+ const next: ShapeRecord = { ...record, project, title: kept.board.title, updated: at, latest: n, drawing: null };
444
531
  writeJsonAtomic(join(folder, "shape.json"), next);
445
532
  return { ok: true, id: record.id, v: n, created: false, record: next };
446
533
  });
@@ -591,8 +678,17 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
591
678
  if (!record) return { ok: false, code: "not_found", message: `No shape ${id}.` };
592
679
  if (record.state === "finished") return { ok: false, code: "finished", message: `${id} is finished. Reopen it to change it.` };
593
680
  if (checked.value.v > record.latest) return { ok: false, code: "no_version", message: `${id} has no version ${checked.value.v}.` };
681
+ const noteId = shapeNoteId(now());
682
+ let picture: string | undefined;
683
+ if (checked.value.picture) {
684
+ // A box's picture: a file the shape keeps, named for its note.
685
+ const name = `note-${noteId}.png`;
686
+ ensureDir(join(folder, "files"));
687
+ writeFileSync(join(folder, "files", name), Buffer.from(checked.value.picture.slice(checked.value.picture.indexOf(",") + 1), "base64"), { mode: 0o600 });
688
+ picture = `files/${name}`;
689
+ }
594
690
  const note: ShapeNote = {
595
- id: shapeNoteId(now()),
691
+ id: noteId,
596
692
  at: iso(),
597
693
  kind: checked.value.kind,
598
694
  v: checked.value.v,
@@ -603,6 +699,9 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
603
699
  from: "you",
604
700
  re: null,
605
701
  delivered: null,
702
+ ...(checked.value.box ? { box: checked.value.box } : {}),
703
+ ...(checked.value.code ? { code: checked.value.code } : {}),
704
+ ...(picture ? { picture } : {}),
606
705
  };
607
706
  appendLine(join(folder, "chat.jsonl"), note);
608
707
  return { ok: true, note };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.28.50",
3
+ "version": "0.28.51",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",