slidemind 0.3.20 → 0.4.1

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 (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -11,14 +11,15 @@
11
11
 
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
+ import { statePath, writeStateFile } from "./deck-paths.mjs";
14
15
 
15
- const SIDECAR = ".reconcile-ack.json";
16
+ const SIDECAR = "reconcile-ack.json";
16
17
 
17
18
  /** { [fileName]: nowHash } — `{}` when the deck has no sidecar (or a broken one). */
18
19
  export function loadReconcileAcks(deckDir) {
19
20
  let parsed;
20
21
  try {
21
- parsed = JSON.parse(fs.readFileSync(path.join(deckDir, SIDECAR), "utf8"));
22
+ parsed = JSON.parse(fs.readFileSync(statePath(deckDir, SIDECAR), "utf8"));
22
23
  } catch {
23
24
  return {};
24
25
  }
@@ -39,8 +40,5 @@ export function saveReconcileAcks(deckDir, acks) {
39
40
  for (const [file, hash] of Object.entries(acks)) {
40
41
  if (typeof hash === "string" && hash) merged[path.basename(file)] = hash;
41
42
  }
42
- fs.writeFileSync(
43
- path.join(deckDir, SIDECAR),
44
- `${JSON.stringify(merged, null, 2)}\n`,
45
- );
43
+ writeStateFile(deckDir, SIDECAR, `${JSON.stringify(merged, null, 2)}\n`);
46
44
  }
@@ -20,13 +20,16 @@
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
22
 
23
+ import { slideOrderPaths } from "../pptx/ooxml.mjs";
24
+ import { foldPickedSlides } from "../pptx/slide-swap.mjs";
23
25
  import {
26
+ TAG_GUID,
24
27
  computeProjection,
25
28
  decodeSourceTags,
29
+ readSlideTags,
26
30
  withoutChartHashes,
27
31
  } from "../pptx/tags.mjs";
28
32
  import { unzip } from "../pptx/zip.mjs";
29
- import { loadConversions } from "./conversions.mjs";
30
33
  import { detectManualEdits, manualEditsError } from "./overwrite-guard.mjs";
31
34
  import { loadReconcileAcks, saveReconcileAcks } from "./reconcile-ack.mjs";
32
35
  import { reconcileSlideGuids } from "./slide-guids.mjs";
@@ -41,7 +44,8 @@ export function collectReconcileInputs({ deckDir, outFile, force = false }) {
41
44
  // the blind build the CLI did before any of this existed.
42
45
  if (force) return null;
43
46
  const edits = detectManualEdits(outFile);
44
- if (edits.length === 0) return null;
47
+ const charted = chartedSlides(outFile).filter((c) => !edits.some((e) => e.slidePath === c.slidePath));
48
+ if (edits.length === 0 && charted.length === 0) return null;
45
49
 
46
50
  // GUID → slide file name. reconcileSlideGuids, not loadSlideGuids: a reorder
47
51
  // renames every file, so the raw sidecar can still be keyed by the old names
@@ -51,33 +55,32 @@ export function collectReconcileInputs({ deckDir, outFile, force = false }) {
51
55
  for (const [slidePath, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
52
56
  fileByGuid.set(guid, path.basename(slidePath));
53
57
  }
54
- // B3 exclusion — CONVERTED slides (`slidemind adopt`). Their drift protection
55
- // is the conversion-gate machinery, which already holds a baseline of the
56
- // ORIGINAL slide; driving a second reconcile from the render bake's baseline
57
- // would have two mechanisms arguing over one slide. Matched by GUID, not file
58
- // name, so a reorder cannot smuggle one back in before the records rehome.
59
- const convertedGuids = new Set(
60
- Object.values(loadConversions(deckDir, { includeSource: false }))
61
- .map((record) => record.guid)
62
- .filter(Boolean),
63
- );
58
+ // A CONVERTED slide (`slidemind adopt`) reconciles like any other, as in the
59
+ // add-in: the conversion gate compares a candidate with the ORIGINAL import,
60
+ // never with what the user changed in the delivered file since, so leaving
61
+ // it out here let the next build overwrite a manual edit without a word.
64
62
 
65
63
  const acks = loadReconcileAcks(deckDir);
66
64
  const reconcile = {};
67
65
  const refusals = [];
68
- for (const edit of edits) {
69
- if (edit.guid && convertedGuids.has(edit.guid)) continue;
66
+ let bytes = null;
67
+ for (const edit of [...edits, ...charted]) {
70
68
  const fileName = edit.guid ? fileByGuid.get(edit.guid) : null;
71
- // B3 exclusion — a slide with no jsx of ours behind it: a native reused
72
- // .pptx, or a slide pushed by another deck. There is nothing to fold an
73
- // edit INTO, so Phase A's refusal still governs it.
69
+ // A slide with no jsx of ours behind it — a native reused .pptx, or a slide
70
+ // pushed by another deck — has nothing to fold an edit INTO, so Phase A's
71
+ // refusal still governs a visible edit to it.
74
72
  if (!fileName) {
75
- refusals.push(edit);
73
+ if (edits.includes(edit)) refusals.push(edit);
76
74
  continue;
77
75
  }
76
+ // A slide holding a chart travels as its package: only the add-in can read
77
+ // a chart, so it projects the current state and folds a chart edit too.
78
+ bytes ??= hasChart(edit.baseline) ? fs.readFileSync(outFile) : null;
78
79
  reconcile[fileName] = {
79
80
  baseline: edit.baseline,
80
- current: edit.current,
81
+ ...(hasChart(edit.baseline)
82
+ ? { package: Buffer.from(foldPickedSlides(bytes, [edit.slideIndex])[0].bytes).toString("base64") }
83
+ : { current: edit.current }),
81
84
  ...(acks[fileName] ? { ack: acks[fileName] } : {}),
82
85
  };
83
86
  }
@@ -85,6 +88,35 @@ export function collectReconcileInputs({ deckDir, outFile, force = false }) {
85
88
  return Object.keys(reconcile).length > 0 ? reconcile : null;
86
89
  }
87
90
 
91
+ /** Does a baked baseline hold a chart — something only the add-in can compare? */
92
+ function hasChart(baseline) {
93
+ return (baseline?.shapes ?? []).some((shape) => shape?.chart != null);
94
+ }
95
+
96
+ /**
97
+ * Slides of the previous output whose baseline holds a chart. Their chart-only
98
+ * edits are invisible to this repo's projection (`chart: null`, the CHART HASH
99
+ * note in pptx/tags.mjs), so they go to the add-in whether or not anything
100
+ * else about them changed.
101
+ */
102
+ function chartedSlides(outFile) {
103
+ if (!outFile || !fs.existsSync(outFile)) return [];
104
+ let files;
105
+ try {
106
+ files = unzip(fs.readFileSync(outFile));
107
+ } catch {
108
+ return [];
109
+ }
110
+ const out = [];
111
+ slideOrderPaths(files).forEach((slidePath, i) => {
112
+ const tags = readSlideTags(files, slidePath);
113
+ const baseline = decodeSourceTags(tags)?.baseline ?? null;
114
+ if (!hasChart(baseline)) return;
115
+ out.push({ slideIndex: i + 1, slidePath, guid: tags[TAG_GUID] ?? null, baseline });
116
+ });
117
+ return out;
118
+ }
119
+
88
120
  // ---------------------------------------------------------------------------
89
121
  // Live deck (push --replace) — plan Phase C2
90
122
  // ---------------------------------------------------------------------------
@@ -228,10 +260,16 @@ export async function collectLiveReconcileInputs({
228
260
  if (!baseline) continue;
229
261
  // The BYTES carry the current state; the TAGS carry the baseline. Never the
230
262
  // other way round — exportAsBase64 strips tags (office-js #6104).
231
- // Chart hashes are dropped from BOTH sides — this repo cannot produce one
232
- // (CHART HASH note in pptx/tags.mjs), and sending a null against the
233
- // baseline's real hash would make the SERVER report a chart edit that never
234
- // happened.
263
+ // A slide holding a chart goes to the add-in as those bytes: only it can
264
+ // read a chart, so it projects the current state and folds a chart edit.
265
+ if (hasChart(baseline)) {
266
+ reconcile[fileName] = {
267
+ baseline,
268
+ package: slide.base64,
269
+ ...(acks[fileName] ? { ack: acks[fileName] } : {}),
270
+ };
271
+ continue;
272
+ }
235
273
  const current = withoutChartHashes(
236
274
  computeProjection(unzip(Buffer.from(slide.base64, "base64"))),
237
275
  );
@@ -311,7 +349,7 @@ export function applyReconcileResponse({ deckDir, sent, result }) {
311
349
  "utf8",
312
350
  );
313
351
  process.stderr.write(
314
- `note: folded ${entry.appliedTextEdits ?? 0} manual text edit(s) from ` +
352
+ `note: folded ${entry.appliedTextEdits ?? 0} manual edit(s) (text, charts) from ` +
315
353
  `PowerPoint into slides/${safeName}\n`,
316
354
  );
317
355
  }
@@ -0,0 +1,175 @@
1
+ // Network half of `slidemind record`: the backend's record service, and
2
+ // nothing else. No filesystem, no deck state — what a response means for the
3
+ // local binding is src/lib/record-state.mjs's job, and applying it is an
4
+ // explicit step the caller takes AFTER the network call (a background sync
5
+ // that wrote straight into the file would discard an edit made while the
6
+ // request was in flight — see synchronization.md).
7
+ //
8
+ // Results are the service's JSON as sent, plus `identity` when the backend
9
+ // advertises stable item identity (contracts.md §6). Refusals arrive as
10
+ // errors carrying `code` (one of REFUSALS' keys, `conflict`, `stale`,
11
+ // `version_unknown`, `backend_ambiguous`) and, where the service sent one,
12
+ // `body`. Legacy wire codes are mapped HERE and only here.
13
+
14
+ // The record service lives at `/api/cli/<kind>/record`, where the kind is the
15
+ // CLIENT's namespace. A backend advertising `sharedRecords.kinds: ["*"]` takes
16
+ // any name, and this client then uses one plain default; a backend that lists
17
+ // kinds is served exactly those. This file names no pack. A caller that knows
18
+ // its kind (a binding's `recordKind`, `--kind`) passes it.
19
+ export const DEFAULT_RECORD_KIND = "record";
20
+ export async function resolveRecordKind(backendURL, explicit = null) {
21
+ if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
22
+ const { backendCapabilities } = await import("../bridge/client/transport.mjs");
23
+ const kinds = (await backendCapabilities(backendURL))?.sharedRecords?.kinds;
24
+ const list = Array.isArray(kinds) ? kinds.filter((k) => typeof k === "string" && k) : [];
25
+ if (list.includes("*")) return DEFAULT_RECORD_KIND;
26
+ if (list.length === 1) return list[0];
27
+ const err = new Error(
28
+ list.length
29
+ ? `this backend serves several record kinds (${list.join(", ")}); name one with --kind <kind>.`
30
+ : "this backend advertises no shared record kind (sharedRecords.kinds); it cannot host a record.",
31
+ );
32
+ err.code = list.length ? "kind_ambiguous" : "unsupported";
33
+ throw err;
34
+ }
35
+ const routeFor = (kind) => `/api/cli/${encodeURIComponent(kind)}/record`;
36
+
37
+ // The refusals a user can act on. Everything else is re-thrown for the
38
+ // top-level handler — a stack trace is a bug report, not a remedy.
39
+ export const REFUSALS = {
40
+ login_required:
41
+ "your Microsoft 365 sign-in is missing or expired — run `slidemind login`, then repeat this command.",
42
+ access_denied:
43
+ "you cannot reach that record. Ask whoever owns the folder to share it with you, then try again.",
44
+ offline:
45
+ "the slidemind backend could not be reached. Nothing was sent and nothing local was changed.",
46
+ not_found: "no record at that link — check the link, or create one first.",
47
+ name_exists:
48
+ "a file of that name already exists in that folder. Pick another --name.",
49
+ sharepoint_write_disabled:
50
+ "this deployment has record writing switched off. Nothing was written.",
51
+ backend_ambiguous:
52
+ "several environments are connected. Name one with --backend <env>.",
53
+ };
54
+
55
+ export function refusalCode(err) {
56
+ if (err?.code === "backend_ambiguous") return "backend_ambiguous";
57
+ // login_required arrives as a 403 too, so it is read before the status.
58
+ if (err?.code === "login_required") return "login_required";
59
+ if (err?.status === 403) return "access_denied";
60
+ if (err?.status === 404 && err?.body?.error === "not_found") return "not_found";
61
+ if (
62
+ err?.code === "bridge_timeout" ||
63
+ /relay unreachable/i.test(String(err?.message ?? ""))
64
+ ) {
65
+ return "offline";
66
+ }
67
+ const named = typeof err?.body?.error === "string" ? err.body.error : null;
68
+ return named && REFUSALS[named] ? named : null;
69
+ }
70
+
71
+ // Which paired backend a record call goes to. Pure over the session list so
72
+ // the rule is testable without a config dir:
73
+ // 1. an explicit scope (`--backend` / SLIDEMIND_BACKEND) must name exactly one;
74
+ // 2. else the binding's own pinned backend, if it is still paired;
75
+ // 3. else the only paired backend;
76
+ // 4. else `backend_ambiguous` — a record lives in one tenant, and the global
77
+ // default is not evidence of which one.
78
+ export function pickRecordBackend(sessions, { binding = null, scoped = false } = {}) {
79
+ const ambiguous = () => {
80
+ const err = new Error(
81
+ `several environments are connected (${sessions.map((s) => s.label).join(", ")}). ` +
82
+ "Name one with --backend <env>.",
83
+ );
84
+ err.code = "backend_ambiguous";
85
+ return err;
86
+ };
87
+ if (scoped) {
88
+ if (sessions.length !== 1) throw ambiguous();
89
+ return sessions[0].backendURL;
90
+ }
91
+ const pinned = binding?.backendURL;
92
+ if (pinned && sessions.some((s) => s.backendURL === pinned)) return pinned;
93
+ if (sessions.length === 1) return sessions[0].backendURL;
94
+ throw ambiguous();
95
+ }
96
+
97
+ export async function resolveRecordBackend(binding = null) {
98
+ const { sessionsInScope } = await import("../bridge/client/transport.mjs");
99
+ const scoped = Boolean(process.env.SLIDEMIND_BACKEND?.trim());
100
+ return pickRecordBackend(sessionsInScope(), { binding, scoped });
101
+ }
102
+
103
+ // `{driveId,itemId}` once the binding carries stable identity, else the url.
104
+ // Never both: the service refuses mixed selectors.
105
+ export function recordSelector(binding) {
106
+ if (binding?.driveId && binding?.itemId) {
107
+ return { driveId: binding.driveId, itemId: binding.itemId };
108
+ }
109
+ return { url: binding?.url ?? null };
110
+ }
111
+
112
+ // The user's OWN identity rides every call: the service reads and writes the
113
+ // file as them, so folder permissions stay the access model.
114
+ async function request(backendURL, method, { kind = null, query = "", body = null } = {}) {
115
+ const { bridgeFetch, graphHeaders } = await import(
116
+ "../bridge/client/transport.mjs"
117
+ );
118
+ const recordKind = await resolveRecordKind(backendURL, kind);
119
+ const headers = { accept: "application/json", ...(await graphHeaders(backendURL)) };
120
+ if (body) headers["content-type"] = "application/json";
121
+ try {
122
+ const res = await bridgeFetch(
123
+ `${routeFor(recordKind)}${query}`,
124
+ { method, headers, ...(body ? { body: JSON.stringify(body) } : {}) },
125
+ backendURL,
126
+ );
127
+ // the kind rides on the result so the binding remembers which service holds the record
128
+ return { ...normalize((await res.json()) ?? {}), recordKind };
129
+ } catch (err) {
130
+ throw typed(err);
131
+ }
132
+ }
133
+
134
+ // One `code` per error so callers switch on it, never on status+body pairs.
135
+ function typed(err) {
136
+ if (!err || typeof err !== "object") return err;
137
+ if (err.status === 409 && err.body?.error === "conflict") err.code = "conflict";
138
+ else if (err.status === 409 && err.body?.error === "stale") err.code = "stale";
139
+ else if (err.body?.error === "version_unknown") err.code = "version_unknown";
140
+ else {
141
+ const code = refusalCode(err);
142
+ if (code) err.code = code;
143
+ }
144
+ return err;
145
+ }
146
+
147
+ // Older services answer without `identity`; a newer one's is passed through
148
+ // only when it is the well-formed object §6 promises.
149
+ function normalize(data) {
150
+ const id = data?.identity;
151
+ if (!id || typeof id !== "object" || typeof id.itemId !== "string") {
152
+ return data;
153
+ }
154
+ return { ...data, identity: id };
155
+ }
156
+
157
+ function queryFor(selector) {
158
+ const q = new URLSearchParams();
159
+ for (const [k, v] of Object.entries(selector)) if (v) q.set(k, v);
160
+ return `?${q.toString()}`;
161
+ }
162
+
163
+ export function createRecord(backendURL, { kind = null, folderUrl, name, plan }) {
164
+ return request(backendURL, "POST", { kind, body: { folderUrl, name, plan } });
165
+ }
166
+
167
+ export function getRecord(backendURL, selector, { kind = null } = {}) {
168
+ return request(backendURL, "GET", { kind, query: queryFor(selector) });
169
+ }
170
+
171
+ // `plan` is sent as given: stripping `approval` from an ordinary save is the
172
+ // caller's decision (record-state.planToSend), not the transport's.
173
+ export function putRecord(backendURL, { kind = null, selector, base, plan }) {
174
+ return request(backendURL, "PUT", { kind, body: { ...selector, base, plan } });
175
+ }
@@ -0,0 +1,151 @@
1
+ // Local half of `slidemind record`: the binding in `state/record.json` and the
2
+ // conflicts sidecar. Content-blind, like the command — it stores what the
3
+ // service sent and never learns what a field means.
4
+ //
5
+ // state/record.json url, name, eTag, versionLabel, the plan as
6
+ // last RECEIVED (`base`), the permanent link,
7
+ // the local file it syncs — plus, once a backend
8
+ // says so, the stable identity: backendURL,
9
+ // tenantId, driveId, itemId, parentId, recordKind.
10
+ // state/record-conflicts.json written only when a sync came back with
11
+ // same-field conflicts.
12
+ //
13
+ // `base` is load-bearing: it is the version this machine loaded, and it is what
14
+ // the service diffs "mine" against. It is updated ONLY from a response
15
+ // (applyRecordResponse), never from the local file.
16
+ //
17
+ // Identity is additive and lazy: a legacy binding stays valid, and the fields
18
+ // fill in from the next response that carries them. Nothing here guesses a
19
+ // backend — see record-client.pickRecordBackend.
20
+
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import { deckRoot, statePath } from "./deck-paths.mjs";
25
+
26
+ export const RECORD_FILE = "record.json";
27
+ export const CONFLICTS_FILE = "record-conflicts.json";
28
+ export const SCHEMA_VERSION = 1;
29
+
30
+ const IDENTITY_FIELDS = ["tenantId", "driveId", "itemId", "parentId"];
31
+
32
+ function readJsonOrNull(file) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(file, "utf8"));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // Write-then-rename: a kill mid-write must not leave half a binding behind.
41
+ function writeJsonAtomic(file, data) {
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ const tmp = `${file}.${process.pid}.tmp`;
44
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
45
+ fs.renameSync(tmp, file);
46
+ return file;
47
+ }
48
+
49
+ export function recordStatePath(deckDir) {
50
+ return statePath(deckDir, RECORD_FILE);
51
+ }
52
+
53
+ export function conflictsPath(deckDir) {
54
+ return statePath(deckDir, CONFLICTS_FILE);
55
+ }
56
+
57
+ export function readRecordState(deckDir) {
58
+ return readJsonOrNull(recordStatePath(deckDir));
59
+ }
60
+
61
+ export function writeRecordState(deckDir, state) {
62
+ return writeJsonAtomic(recordStatePath(deckDir), state);
63
+ }
64
+
65
+ export function readConflicts(deckDir) {
66
+ return readJsonOrNull(conflictsPath(deckDir));
67
+ }
68
+
69
+ export function writeConflicts(deckDir, body) {
70
+ return writeJsonAtomic(conflictsPath(deckDir), {
71
+ merged: body.merged ?? null,
72
+ conflicts: Array.isArray(body.conflicts) ? body.conflicts : [],
73
+ current: body.current ?? null,
74
+ eTag: body.eTag ?? null,
75
+ });
76
+ }
77
+
78
+ export function clearConflicts(deckDir) {
79
+ fs.rmSync(conflictsPath(deckDir), { force: true });
80
+ }
81
+
82
+ // The local file is stored relative to the deck when it lives inside it, so a
83
+ // moved or copied deck still knows which file its record syncs.
84
+ export function storeFile(deckDir, file) {
85
+ const rel = path.relative(deckRoot(deckDir), file);
86
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
87
+ ? rel.split(path.sep).join("/")
88
+ : file;
89
+ }
90
+
91
+ export function resolveStoredFile(deckDir, stored) {
92
+ if (!stored) return null;
93
+ return path.isAbsolute(stored) ? stored : path.join(deckRoot(deckDir), stored);
94
+ }
95
+
96
+ export function writeRecordFile(file, plan) {
97
+ writeJsonAtomic(file, plan);
98
+ }
99
+
100
+ // The link a colleague opens. The service mints it (it alone knows the page's
101
+ // origin); `url` is the fallback so a response without one still prints
102
+ // something a human can follow.
103
+ export const pageLink = (data, fallback = null) => data?.link ?? fallback ?? data?.url ?? null;
104
+
105
+ // The binding a response describes. `previous` keeps whatever the response
106
+ // leaves out — a sync answers about a record the binding already identifies,
107
+ // so losing the url (or the identity) here would strand the deck.
108
+ export function recordStateFrom(
109
+ data,
110
+ { file, deckDir, link, backendURL = null, previous = null },
111
+ ) {
112
+ const identity = data?.identity ?? {};
113
+ const state = {
114
+ schemaVersion: SCHEMA_VERSION,
115
+ url: data.url ?? previous?.url ?? null,
116
+ name: data.name ?? previous?.name ?? null,
117
+ eTag: data.eTag ?? null,
118
+ versionLabel: data.versionLabel ?? null,
119
+ base: data.plan ?? null,
120
+ link: link ?? previous?.link ?? pageLink(data),
121
+ file: storeFile(deckDir, file),
122
+ syncedAt: new Date().toISOString(),
123
+ backendURL: backendURL ?? previous?.backendURL ?? null,
124
+ recordKind: previous?.recordKind ?? data?.recordKind ?? null,
125
+ };
126
+ for (const k of IDENTITY_FIELDS) {
127
+ const v = identity[k] ?? previous?.[k] ?? null;
128
+ if (v !== null) state[k] = v;
129
+ }
130
+ return state;
131
+ }
132
+
133
+ // What an ordinary save sends. `approval` is the service's to keep: it is only
134
+ // ever accepted from the one command that means it, and stripped from every
135
+ // other write so a sync cannot carry an approval nobody asked for.
136
+ export function planToSend(plan, { approval = false } = {}) {
137
+ const mine = { ...plan };
138
+ if (!approval) delete mine.approval;
139
+ return mine;
140
+ }
141
+
142
+ // The explicit writeback step after a network call succeeded: plan file,
143
+ // binding, and the conflicts the write settled. Callers decide WHEN — never
144
+ // inside the request, and never for a response that is not this deck's.
145
+ export function applyRecordResponse(deckDir, data, { file, link, backendURL, previous }) {
146
+ writeRecordFile(file, data.plan);
147
+ const state = recordStateFrom(data, { file, deckDir, link, backendURL, previous });
148
+ writeRecordState(deckDir, state);
149
+ clearConflicts(deckDir);
150
+ return state;
151
+ }