slidemind 0.3.20 → 0.4.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.
Files changed (185) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,359 @@
1
+ // The deck's remote association, in one honest card (contracts §2 destination
2
+ // file, synchronization.md "Binding and publication").
3
+ //
4
+ // state/destination.json the FOLDER a pack deck's plan and presentation go
5
+ // to — folder only, never a mutable eTag
6
+ // state/record.json the shared plan binding (record-state.mjs)
7
+ // state/remote.json the published PPTX binding (remote.mjs)
8
+ // state/workspace-sync.json the plan queue's state (sync lane)
9
+ //
10
+ // `remoteStatus` reads disk and the in-memory capability cache only — it is
11
+ // called on every deck view and must never turn a page load into a network
12
+ // call. "Synced" is the queue's word, not this module's: a chosen folder is
13
+ // destination-only until a record is confirmed, and writes switched off by
14
+ // the deployment show as exactly that.
15
+ //
16
+ // Two write answers, never folded into one: `writes` is the shared PLAN record
17
+ // (the record service AND the deployment's policy), `publishWrites` is the
18
+ // PRESENTATION (the policy alone). A deployment without the record service
19
+ // still publishes the .pptx; one flag for both showed "read-only" on a deck
20
+ // whose presentation was one build away from the folder.
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ import { requireReviewApproved } from "../builders/review.mjs";
26
+ import { backendCapabilitiesCache, backendCapabilitiesResult, sharedRecordsSupport } from "../bridge/client/transport.mjs";
27
+ import { deckSlug, planPageOwner, resolveWorkspace, statePath } from "../lib/deck-paths.mjs";
28
+ import { readRecordState } from "../lib/record-state.mjs";
29
+ import { readRemote } from "../lib/remote.mjs";
30
+ import { revisionStatus } from "../lib/revision.mjs";
31
+ import { coded, writeJsonAtomic } from "./registry.mjs";
32
+
33
+ export const DESTINATION_FILE = "destination.json";
34
+ export const SYNC_FILE = "workspace-sync.json";
35
+ export const SCHEMA_VERSION = 1;
36
+
37
+ export function destinationPath(workspaceDir) {
38
+ return statePath(workspaceDir, DESTINATION_FILE);
39
+ }
40
+
41
+ function readJson(file) {
42
+ let raw;
43
+ try {
44
+ raw = fs.readFileSync(file, "utf8");
45
+ } catch {
46
+ return null;
47
+ }
48
+ try {
49
+ return JSON.parse(raw);
50
+ } catch {
51
+ return { malformed: true, file };
52
+ }
53
+ }
54
+
55
+ /** The saved destination, null, or `{malformed,file}` — a broken file is reported, never reset. */
56
+ export function readDestination(workspaceDir) {
57
+ const d = readJson(destinationPath(workspaceDir));
58
+ if (d && !d.malformed && d.schemaVersion !== SCHEMA_VERSION) return { malformed: true, file: destinationPath(workspaceDir) };
59
+ return d;
60
+ }
61
+
62
+ export function writeDestination(workspaceDir, dest) {
63
+ const record = { schemaVersion: SCHEMA_VERSION, ...dest, updatedAt: new Date().toISOString() };
64
+ writeJsonAtomic(destinationPath(workspaceDir), record);
65
+ return record;
66
+ }
67
+
68
+ /** A structurally editable plan record exists: the pack's `plan.json` (or the declared record) parses. */
69
+ export function hasEditablePlan(workspaceDir) {
70
+ const owner = planPageOwner(workspaceDir);
71
+ const rel = owner.owner === "core" ? null : owner.recordPath ?? "plan.json";
72
+ if (!rel) return false;
73
+ const v = readJson(path.join(workspaceDir, rel));
74
+ return Boolean(v && !v.malformed && typeof v === "object");
75
+ }
76
+
77
+ const hostOf = (url) => {
78
+ try {
79
+ return new URL(url).hostname;
80
+ } catch {
81
+ return null;
82
+ }
83
+ };
84
+
85
+ async function defaultSession({ backendURL }) {
86
+ const { sessionsInScope } = await import("../bridge/client/transport.mjs");
87
+ const { getSharePointToken, sharePointResource } = await import("../bridge/graph-auth.mjs");
88
+ const { pickBackend } = await import("./remote-links.mjs");
89
+ const provider = await import("../bridge/sharepoint.mjs");
90
+ const { getDefaultBackend } = await import("../bridge/config.mjs");
91
+ const chosen = pickBackend(sessionsInScope(), { backendURL, fallback: getDefaultBackend() });
92
+ const origin = await sharePointResource(chosen);
93
+ if (!origin) throw coded("unsupported_link", "this deployment publishes no SharePoint origin");
94
+ const token = await getSharePointToken(chosen);
95
+ if (!token) throw coded("login_required", "your Microsoft 365 sign-in is missing or expired — run `slidemind login`");
96
+ return { backendURL: chosen, provider, client: provider.createClient({ origin, token }) };
97
+ }
98
+
99
+ // The three-valued write answer: deployment policy AND shared-record support,
100
+ // never folded into one boolean. `refresh:true` bypasses the cache — this runs
101
+ // right before a decision, not on a page view.
102
+ async function defaultWrites(backendURL, { refresh, kind = null }) {
103
+ if (!refresh) {
104
+ const cached = backendCapabilitiesCache.get(backendURL);
105
+ if (!cached) return "unknown";
106
+ }
107
+ const s = await sharedRecordsSupport(backendURL, { refresh, kind });
108
+ if (s.status === "unknown") return "unknown";
109
+ return s.status === "supported" && s.sharepointPublish ? "enabled" : "disabled";
110
+ }
111
+
112
+ // The presentation's write answer: the deployment's `sharepointPublish` policy
113
+ // alone — publishing a .pptx needs no record service.
114
+ async function defaultPublishWrites(backendURL, { refresh }) {
115
+ if (!refresh && !backendCapabilitiesCache.get(backendURL)) return "unknown";
116
+ const probe = await backendCapabilitiesResult(backendURL, { refresh });
117
+ if (probe.status === "unknown") return "unknown";
118
+ return probe.caps?.sharepointPublish === true ? "enabled" : "disabled";
119
+ }
120
+
121
+ /**
122
+ * A build `slidemind publish` would upload as it stands: a candidate that still
123
+ * matches the slides, from a deck whose preview passed its one-time review (an
124
+ * adopted deck needs none). Disk only.
125
+ */
126
+ export function hasPublishableBuild(workspaceDir) {
127
+ let candidate;
128
+ try {
129
+ candidate = revisionStatus(workspaceDir)?.candidate;
130
+ } catch {
131
+ return false;
132
+ }
133
+ return Boolean(candidate && !candidate.stale) && requireReviewApproved(workspaceDir) === null;
134
+ }
135
+
136
+ /** `slidemind publish <deck> --json` as a child: every gate it enforces stays where it is. */
137
+ async function defaultPublish({ deckRoot, backendURL }) {
138
+ const { runCli } = await import("./remote-links.mjs");
139
+ return runCli(["publish", deckRoot], { env: { ...(backendURL ? { SLIDEMIND_BACKEND: backendURL } : {}), SLIDEMIND_NO_UPDATE_CHECK: "1" } });
140
+ }
141
+
142
+ export const defaultDeps = { session: defaultSession, writes: defaultWrites, publishWrites: defaultPublishWrites, hasEditablePlan, hasPublishableBuild, publish: defaultPublish };
143
+
144
+ /**
145
+ * Point a deck at a folder. Verifies the folder, refuses a tenant that differs
146
+ * from what the deck already binds, refuses a name that is taken (no implicit
147
+ * overwrite — the caller offers "open existing" or "another name"), then saves
148
+ * the destination even when writes are disabled: it is destination-only then,
149
+ * and says so. Record creation itself is the sync lane's (`shouldCreatePlan`).
150
+ */
151
+ export async function connectDestination({ deckRoot, folderRef, pptxName = null, backendURL = null }, overrides = {}) {
152
+ const d = { ...defaultDeps, ...overrides };
153
+ const ws = resolveWorkspace(deckRoot);
154
+ const s = await d.session({ backendURL });
155
+ const folder = await s.provider.getFolder(s.client, folderRef);
156
+
157
+ const record = readRecordState(ws);
158
+ let pptx = null;
159
+ try {
160
+ pptx = readRemote(ws);
161
+ } catch {
162
+ // an unfinished transaction is not a tenant mismatch
163
+ }
164
+ const folderHost = hostOf(folder.webUrl);
165
+ if (record?.tenantId && folder.tenantId && record.tenantId !== folder.tenantId) {
166
+ throw coded("cross_tenant", `${deckSlug(ws)}'s shared plan lives in another tenant; a destination cannot cross tenants`);
167
+ }
168
+ if (pptx?.tenant && folderHost && pptx.tenant !== folderHost) {
169
+ throw coded("cross_tenant", `${deckSlug(ws)} publishes to ${pptx.tenant}; a destination on ${folderHost} would cross tenants`);
170
+ }
171
+ if (record?.backendURL && record.backendURL !== s.backendURL) {
172
+ throw coded("cross_tenant", `${deckSlug(ws)}'s shared plan is bound to another environment (${record.backendURL})`);
173
+ }
174
+
175
+ const stem = String(pptxName ?? `${deckSlug(ws)}.pptx`).replace(/\.pptx$/i, "");
176
+ if (!stem || /[\\/]/.test(stem)) throw coded("validation_failed", "pptxName must be a plain .pptx file name");
177
+ const names = { pptxName: `${stem}.pptx`, name: `${stem}-plan.json` };
178
+ for (const [key, name] of Object.entries(names)) {
179
+ // An item bound to THIS deck under that name is not a collision.
180
+ if (key === "pptxName" && pptx?.name === name && pptx.parentId === folder.id) continue;
181
+ if (key === "name" && record?.name === name && record.parentId === folder.id) continue;
182
+ const hit = await s.provider.resolveDestination(s.client, { driveId: folder.driveId, itemId: folder.id }, name);
183
+ if (hit.exists) {
184
+ throw coded("name_exists", `${name} already exists in ${folder.name}`, {
185
+ details: { name, existing: { id: hit.item.itemId, driveId: hit.item.driveId, name: hit.item.name, webUrl: hit.item.webUrl, kind: hit.item.kind } },
186
+ choices: ["open-existing", "use-another-name"],
187
+ });
188
+ }
189
+ }
190
+
191
+ const writes = await d.writes(s.backendURL, { refresh: true });
192
+ const publishWrites = await d.publishWrites(s.backendURL, { refresh: true });
193
+ const editable = d.hasEditablePlan(ws);
194
+ const destination = writeDestination(ws, {
195
+ backendURL: s.backendURL,
196
+ tenantId: folder.tenantId ?? null,
197
+ driveId: folder.driveId,
198
+ itemId: folder.id,
199
+ parentId: folder.parentId ?? null,
200
+ webUrl: folder.webUrl ?? null,
201
+ folderName: folder.name ?? null,
202
+ ...names,
203
+ state: editable ? "ready_to_create" : "waiting_for_plan",
204
+ writes,
205
+ publishWrites,
206
+ connectedAt: new Date().toISOString(),
207
+ });
208
+ // A folder connected AFTER the deck was built: the build is uploaded now
209
+ // (`uploadToDestination`); every later build publishes itself (build-pptx).
210
+ const shouldPublish = publishWrites === "enabled" && !pptx && d.hasPublishableBuild(ws);
211
+ return { destination, writes, publishWrites, shouldCreatePlan: editable && writes === "enabled" && !record?.url, shouldPublish };
212
+ }
213
+
214
+ // The reasons the card words for a person; anything else reads "could not be uploaded".
215
+ const UPLOAD_REASONS = {
216
+ name_conflict: "name_exists",
217
+ deck_busy: "deck_busy",
218
+ login_required: "login_required",
219
+ remote_saving_disabled: "write_disabled",
220
+ };
221
+
222
+ /**
223
+ * Upload the deck's approved build into its saved folder — the first publish of
224
+ * a folder connected after the build. `slidemind publish` runs as a child, so
225
+ * the review gate, ownership and the create-only route (a taken name is
226
+ * refused, never overwritten) stay the command's. The outcome lands in
227
+ * destination.json's `upload` for the card: `uploading`, then `done` or
228
+ * `failed` with a reason the page can word.
229
+ */
230
+ export async function uploadToDestination(deckRoot, overrides = {}) {
231
+ const d = { ...defaultDeps, ...overrides };
232
+ const ws = resolveWorkspace(deckRoot);
233
+ const mark = (upload) => {
234
+ const current = readDestination(ws);
235
+ if (current && !current.malformed) writeDestination(ws, { ...current, upload: { ...upload, at: new Date().toISOString() } });
236
+ };
237
+ const dest = readDestination(ws);
238
+ if (!dest || dest.malformed) return { state: "skipped" };
239
+ mark({ state: "uploading" });
240
+ let run;
241
+ try {
242
+ run = await d.publish({ deckRoot, backendURL: dest.backendURL ?? null });
243
+ } catch (err) {
244
+ run = { status: 1, json: null, stderr: err?.message ?? String(err) };
245
+ }
246
+ if (run.status === 0 && run.json?.status === "committed") {
247
+ mark({ state: "done" });
248
+ return { state: "done", publish: run.json };
249
+ }
250
+ const upload = { state: "failed", reason: UPLOAD_REASONS[run.json?.reason] ?? "publish_failed" };
251
+ mark(upload);
252
+ return { ...upload, publish: run.json ?? null };
253
+ }
254
+
255
+ /**
256
+ * The destination a deck already implies: the saved one, else the folder its
257
+ * PUBLISHED presentation sits in (synchronization.md "Binding and publication"
258
+ * — an existing PPTX binding without a record resolves its parent folder).
259
+ * One network round when derived; null when the deck names no folder at all.
260
+ */
261
+ export async function ensureDestination(deckRoot, { backendURL = null } = {}, overrides = {}) {
262
+ const ws = resolveWorkspace(deckRoot);
263
+ const saved = readDestination(ws);
264
+ if (saved) return saved;
265
+ let pptx = null;
266
+ try {
267
+ pptx = readRemote(ws);
268
+ } catch {
269
+ return null;
270
+ }
271
+ if (!pptx?.driveId || !pptx?.itemId) return null;
272
+ const d = { ...defaultDeps, ...overrides };
273
+ const s = await d.session({ backendURL });
274
+ const item = await s.provider.getItem(s.client, { driveId: pptx.driveId, itemId: pptx.itemId });
275
+ if (!item?.parentId) return null;
276
+ const { destination } = await connectDestination(
277
+ { deckRoot, folderRef: { driveId: item.driveId ?? pptx.driveId, itemId: item.parentId }, pptxName: pptx.name ?? item.name, backendURL: s.backendURL },
278
+ { ...overrides, session: async () => s },
279
+ );
280
+ return destination;
281
+ }
282
+
283
+ /**
284
+ * The card, from disk: shared plan vs published PPTX vs a mere destination,
285
+ * each with its own state, plus the write policy as last observed.
286
+ */
287
+ export async function remoteStatus(deckRoot, overrides = {}) {
288
+ const d = { ...defaultDeps, ...overrides };
289
+ const ws = resolveWorkspace(deckRoot);
290
+ const record = readRecordState(ws);
291
+ let pptx = null;
292
+ try {
293
+ pptx = readRemote(ws);
294
+ } catch {
295
+ pptx = null;
296
+ }
297
+ const destination = readDestination(ws);
298
+ const sync = readJson(statePath(ws, SYNC_FILE));
299
+ const backendURL = record?.backendURL ?? destination?.backendURL ?? null;
300
+ let writes = "unknown";
301
+ if (backendURL) {
302
+ try {
303
+ writes = await d.writes(backendURL, { refresh: false, kind: record?.recordKind ?? null });
304
+ } catch {
305
+ writes = "unknown";
306
+ }
307
+ }
308
+ if (writes === "unknown" && destination?.writes) writes = destination.writes;
309
+ let publishWrites = "unknown";
310
+ if (backendURL) {
311
+ try {
312
+ publishWrites = await d.publishWrites(backendURL, { refresh: false });
313
+ } catch {
314
+ publishWrites = "unknown";
315
+ }
316
+ }
317
+ if (publishWrites === "unknown" && destination?.publishWrites) publishWrites = destination.publishWrites;
318
+
319
+ let plan;
320
+ if (record?.url) {
321
+ const blocked = sync && !sync.malformed && ["blocked", "outcome-unknown"].includes(sync.state);
322
+ plan = {
323
+ state: blocked ? "blocked" : "shared",
324
+ link: record.link ?? record.url,
325
+ name: record.name ?? null,
326
+ lastConfirmedAt: sync?.lastConfirmedAt ?? record.syncedAt ?? null,
327
+ sync: sync && !sync.malformed ? sync.state : null,
328
+ reason: blocked ? sync.lastError?.code ?? sync.state : writes === "disabled" ? "write_disabled" : null,
329
+ };
330
+ } else if (destination && !destination.malformed) {
331
+ plan = {
332
+ state: "destination-only",
333
+ link: destination.webUrl,
334
+ name: destination.name,
335
+ lastConfirmedAt: null,
336
+ sync: null,
337
+ reason: writes === "disabled" ? "write_disabled" : writes === "unknown" ? "capability_unknown" : destination.state,
338
+ };
339
+ } else {
340
+ plan = { state: "none", link: null, name: null, lastConfirmedAt: null, sync: null, reason: null };
341
+ }
342
+
343
+ const pptxCard = pptx
344
+ ? { state: pptx.lastReceipt ? "published" : "bound", link: pptx.webUrl ?? null, name: pptx.name ?? null, lastConfirmedAt: pptx.lastReceipt?.confirmedAt ?? pptx.lastPulledAt ?? null }
345
+ : { state: "none", link: null, name: null, lastConfirmedAt: null };
346
+
347
+ return {
348
+ plan,
349
+ pptx: pptxCard,
350
+ destination: destination && !destination.malformed
351
+ ? { webUrl: destination.webUrl, folderName: destination.folderName ?? null, name: destination.name, pptxName: destination.pptxName, state: destination.state, upload: destination.upload ?? null }
352
+ : destination?.malformed
353
+ ? { malformed: true }
354
+ : null,
355
+ writes,
356
+ publishWrites,
357
+ backendURL,
358
+ };
359
+ }