@brianmsm/quarto-bento 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Studio, reviewed QMD synchronization and an offline standalone Bento HTML
6
6
  output.
7
7
 
8
8
  This npm CLI package is named `@brianmsm/quarto-bento` and currently targets
9
- **Linux only**. Version `0.1.0` is a development release, not a stable v1.
9
+ **Linux only**. Version `0.1.1` is a development release, not a stable v1.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -26,7 +26,7 @@ executable code you trust.
26
26
  With Node, Deno and Quarto already installed, install the pinned CLI version:
27
27
 
28
28
  ```sh
29
- npm install --global --ignore-scripts @brianmsm/quarto-bento@0.1.0
29
+ npm install -g @brianmsm/quarto-bento@0.1.1
30
30
  qbento --version
31
31
  qbento doctor
32
32
  ```
@@ -34,8 +34,8 @@ qbento doctor
34
34
  If your npm global prefix is not writable, use a user-owned prefix instead:
35
35
 
36
36
  ```sh
37
- npm install --global --ignore-scripts --prefix "$HOME/.local" \
38
- @brianmsm/quarto-bento@0.1.0
37
+ npm install -g --prefix "$HOME/.local" \
38
+ @brianmsm/quarto-bento@0.1.1
39
39
  export PATH="$HOME/.local/bin:$PATH"
40
40
  ```
41
41
 
@@ -45,10 +45,17 @@ Mise can manage Node, Deno and the npm CLI together. Quarto remains a separate
45
45
  user-installed dependency:
46
46
 
47
47
  ```sh
48
- mise use -g node@26.10.0 deno@2.9.6 npm:@brianmsm/quarto-bento@0.1.0
48
+ mise use -g node@26.10.0 deno@2.9.6 \
49
+ 'npm:@brianmsm/quarto-bento[allow_low_downloads=true]@0.1.1'
49
50
  qbento doctor
50
51
  ```
51
52
 
53
+ Recent mise versions require explicit approval for new or low-download packages.
54
+ `allow_low_downloads=true` approves only this package after checking its name
55
+ and publisher; it does not enable build scripts or change global security
56
+ settings. See
57
+ [mise npm reputation checks](https://mise.jdx.dev/dev-tools/backends/npm#low-download-and-new-packages).
58
+
52
59
  The package has no npm install hooks or JavaScript package dependencies. The
53
60
  [installation guide](https://github.com/brianmsm/quarto-bento/blob/main/docs/NPM.md)
54
61
  also covers local tarball installation, requirements and supported scope.
@@ -54,6 +54,6 @@ The full source checkout contains development, acceptance and CI documentation.
54
54
  Those source-only files and verification artifacts are not part of this portable
55
55
  directory. The separate
56
56
  [npm CLI package](https://github.com/brianmsm/quarto-bento/blob/main/docs/NPM.md)
57
- wraps the portable runtime with a `qbento` launcher for local prefix
58
- installation; its registry publication and mise-based registry installation are
59
- still pending.
57
+ wraps the portable runtime with a `qbento` launcher. Version `0.1.0` is
58
+ available for Linux through npm and mise; see that guide for installation
59
+ commands and external Node, Deno and Quarto requirements.
@@ -3,7 +3,7 @@
3
3
  "kind": "development-portable",
4
4
  "qbentoSource": {
5
5
  "kind": "git",
6
- "commit": "05e70bbefebad5f540083bba25444dda0caef0d8",
6
+ "commit": "281d90bcfbbf003c4b7547fe7522c0f22cd7d151",
7
7
  "dirty": false
8
8
  },
9
9
  "bento": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  {
24
24
  "path": "PACKAGING.md",
25
- "bytes": 3378,
26
- "sha256": "1b493f5a9e9911f37babdc3011d3dc31be9518acbb611427073158e62515e8a8",
25
+ "bytes": 3418,
26
+ "sha256": "715fd578bd128109feb5c173a4cf2def56f5dafae90a9f40e597dfc2a991d1a2",
27
27
  "executable": false
28
28
  },
29
29
  {
@@ -100,14 +100,14 @@
100
100
  },
101
101
  {
102
102
  "path": "runtime/src/cli.ts",
103
- "bytes": 7860,
104
- "sha256": "2c7d208b6d0433f1bfac734b6ccc13ccbef9148563ed7052415c9f2fe7df4a25",
103
+ "bytes": 8288,
104
+ "sha256": "8228662370e715dc1f8a0a35557534494bb9ae6aa5199a620116531cc5717ff1",
105
105
  "executable": false
106
106
  },
107
107
  {
108
108
  "path": "runtime/src/core/agent.ts",
109
- "bytes": 18163,
110
- "sha256": "c0064079521e2f3644f774eb41e40c3c5bf7a6e0b09e263900e5d7d94b89b83d",
109
+ "bytes": 17613,
110
+ "sha256": "4c0052728393470b44b651a473dd62640a3d6f7fc69ff30bec73f4a2fa97b430",
111
111
  "executable": false
112
112
  },
113
113
  {
@@ -190,8 +190,8 @@
190
190
  },
191
191
  {
192
192
  "path": "runtime/src/core/project.ts",
193
- "bytes": 25362,
194
- "sha256": "d76c2d968929770e6e20b556eb1d66eb3382b7e70f68fb5af50876a645cddaae",
193
+ "bytes": 28033,
194
+ "sha256": "14c9f362149fdab17fd77c6b0f2f799595fb0b6ab5b32133fc9484092b15f7a8",
195
195
  "executable": false
196
196
  },
197
197
  {
@@ -220,8 +220,8 @@
220
220
  },
221
221
  {
222
222
  "path": "runtime/src/core/sync.ts",
223
- "bytes": 84614,
224
- "sha256": "0536e9e53719d859bbd551ff1a77daf2fb26c8c479ef98f0e6e5f065e1eb6b38",
223
+ "bytes": 89455,
224
+ "sha256": "96d2f32d2cebe259bb973d7ed7affb8b5fb5a5662b89f37e106c7415d60a98aa",
225
225
  "executable": false
226
226
  },
227
227
  {
@@ -580,8 +580,8 @@
580
580
  },
581
581
  {
582
582
  "path": "runtime/src/studio/client.js",
583
- "bytes": 26480,
584
- "sha256": "8513fcac98f8289b086d5d8d493a01dfd11c82c8063e9b1e164b74f252d3d651",
583
+ "bytes": 30201,
584
+ "sha256": "b8332ae52fa538fd41356fdc47d96b7391701ee64c57388ab7edc96ccc61a3da",
585
585
  "executable": false
586
586
  },
587
587
  {
@@ -592,8 +592,8 @@
592
592
  },
593
593
  {
594
594
  "path": "runtime/src/studio/index.html",
595
- "bytes": 11633,
596
- "sha256": "9904a23f2c04dda243b3cc67b1be135160aa19cc0e6abaea586a52078f5cb821",
595
+ "bytes": 11979,
596
+ "sha256": "18196400f39939f2fc4fb773bbba39355558d6b375ba2c7815f423e8dcb5e38d",
597
597
  "executable": false
598
598
  },
599
599
  {
@@ -616,8 +616,8 @@
616
616
  },
617
617
  {
618
618
  "path": "runtime/src/studio/server.ts",
619
- "bytes": 16113,
620
- "sha256": "2eb1a146f4dfc9e2006ac93a3e9779da8dd4d1135d522c751f655128a00eafdc",
619
+ "bytes": 17070,
620
+ "sha256": "ea54191f6d8c453de2ac5193dc388ec98933c42e6948a371cf6d17f70e091f25",
621
621
  "executable": false
622
622
  },
623
623
  {
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env --allow-net=127.0.0.1
2
2
  import { captureProject, readProject, renderProject } from "./core/project.ts";
3
- import { applySync, previewSync } from "./core/sync.ts";
3
+ import {
4
+ applySync,
5
+ previewSelectedSync,
6
+ previewSync,
7
+ readSyncSelectionFile,
8
+ } from "./core/sync.ts";
4
9
  import { recoverProject } from "./core/recovery.ts";
5
10
  import { extractDocument } from "./render/html.ts";
6
11
  import { startStudio } from "./studio/server.ts";
@@ -20,7 +25,7 @@ capture [edited.html] Explicitly capture native local HTML edits
20
25
  status | diff Inspect durable pending changes
21
26
  get Read the saved deck, revision, provenance and pending edits
22
27
  patch <payload.json> Apply bounded structured edits to saved state; never writes QMD
23
- sync [--apply plan.json] Preview source changes or apply an inspected plan
28
+ sync [--select selection.json | --apply plan.json] Preview all/selected source changes or apply an inspected plan
24
29
  reconcile [--apply resolution.json] Execute conflict preview, or apply {plan,choices} to saved state
25
30
  recover [inspect|finish|rollback] Inspect or explicitly recover an interrupted operation
26
31
  init [--extension-only] Install the offline Quarto integration without replacing existing configuration
@@ -86,16 +91,23 @@ if (import.meta.main) {
86
91
  }
87
92
  case "sync": {
88
93
  if (!source) throw new Error("QMD path required");
89
- if (extra === "--apply") {
90
- if (!Deno.args[3]) {
91
- throw new Error("Inspected JSON plan path required");
92
- }
94
+ if (extra === "--apply" && Deno.args[3] && Deno.args.length === 4) {
93
95
  result = await applySync(
94
96
  source,
95
97
  JSON.parse(await Deno.readTextFile(Deno.args[3])),
96
98
  );
97
- } else if (extra === undefined) result = await previewSync(source);
98
- else throw new Error("Use sync source.qmd [--apply plan.json]");
99
+ } else if (
100
+ extra === "--select" && Deno.args[3] && Deno.args.length === 4
101
+ ) {
102
+ result = await previewSelectedSync(
103
+ source,
104
+ await readSyncSelectionFile(Deno.args[3]),
105
+ );
106
+ } else if (extra === undefined && Deno.args.length === 2) {
107
+ result = await previewSync(source);
108
+ } else {throw new Error(
109
+ "Use sync source.qmd [--select selection.json | --apply plan.json]",
110
+ );}
99
111
  break;
100
112
  }
101
113
  case "recover": {
@@ -165,7 +177,8 @@ if (import.meta.main) {
165
177
  } catch (error) {
166
178
  if (
167
179
  !(error instanceof Error) ||
168
- !error.message.startsWith("NO_BASELINE:")
180
+ ((error as { code?: string }).code !== "NO_BASELINE" &&
181
+ !error.message.startsWith("NO_BASELINE:"))
169
182
  ) throw error;
170
183
  await renderProject(source);
171
184
  }
@@ -1,6 +1,5 @@
1
1
  /** Bounded pure-data agent operations over the same saved project as Studio. */
2
2
  import { normalizeNotesText, notesIdentity } from "../source/notes.ts";
3
- import { basename, join } from "node:path";
4
3
  import { projectPaths, readProject, saveProject } from "./project.ts";
5
4
  import { applySlideAction } from "./slide-actions.ts";
6
5
  import { assertJson } from "../state/json.ts";
@@ -253,26 +252,8 @@ function validatePatch(value: unknown): asserts value is AgentPatch {
253
252
  }
254
253
  }
255
254
 
256
- /** Refuse reading another QMD's deck from a shared directory. */
255
+ /** Validate agent data after the shared read-only source binding check. */
257
256
  async function readBoundProject(source: string) {
258
- const paths = projectPaths(source);
259
- let binding;
260
- try {
261
- binding = JSON.parse(
262
- await Deno.readTextFile(join(paths.stateDir, "project.json")),
263
- );
264
- } catch (error) {
265
- if (error instanceof Deno.errors.NotFound) {
266
- fail("NO_BASELINE", "Render this QMD before using the agent API.");
267
- }
268
- throw error;
269
- }
270
- if (binding?.version !== 1 || binding.source !== basename(paths.source)) {
271
- fail(
272
- "PROJECT_SOURCE_MISMATCH",
273
- ".qbento belongs to a different QMD source.",
274
- );
275
- }
276
257
  const state = await readProject(source);
277
258
  assertProjectDocument(state.emitted);
278
259
  return state;
@@ -89,6 +89,88 @@ async function exists(path: string) {
89
89
  throw e;
90
90
  }
91
91
  }
92
+ /** Check persisted source identity without creating state, locks or recovery files. */
93
+ async function readProjectBinding(
94
+ paths: ReturnType<typeof projectPaths>,
95
+ ): Promise<boolean> {
96
+ let stateInfo;
97
+ try {
98
+ stateInfo = await Deno.lstat(paths.stateDir);
99
+ } catch (error) {
100
+ if (error instanceof Deno.errors.NotFound) return false;
101
+ throw error;
102
+ }
103
+ if (stateInfo.isSymlink || !stateInfo.isDirectory) {
104
+ throw new StateError(
105
+ "UNSAFE_STATE_PATH",
106
+ ".qbento must be a real directory.",
107
+ );
108
+ }
109
+ const identity = resolve(paths.stateDir, "project.json");
110
+ let info;
111
+ try {
112
+ info = await Deno.lstat(identity);
113
+ } catch (error) {
114
+ if (error instanceof Deno.errors.NotFound) {
115
+ // Missing identity is not a new project when durable or recoverable state
116
+ // remains. In particular, `edit` must never adopt it for another QMD.
117
+ for (
118
+ const file of [
119
+ "state.json",
120
+ "state.previous.json",
121
+ "render-transaction.json",
122
+ "sync-transaction.json",
123
+ "sync-approved.json",
124
+ ]
125
+ ) {
126
+ if (await exists(resolve(paths.stateDir, file))) {
127
+ throw new StateError(
128
+ "PROJECT_BINDING_MISSING",
129
+ "Saved project state has no source binding. Restore .qbento/project.json from the matching project backup before continuing.",
130
+ );
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ throw error;
136
+ }
137
+ if (info.isSymlink || !info.isFile) {
138
+ throw new StateError(
139
+ "UNSAFE_STATE_PATH",
140
+ ".qbento/project.json must be a regular file.",
141
+ );
142
+ }
143
+ let binding;
144
+ try {
145
+ binding = JSON.parse(await Deno.readTextFile(identity));
146
+ } catch (error) {
147
+ if (!(error instanceof SyntaxError)) throw error;
148
+ throw new StateError(
149
+ "PROJECT_SOURCE_MISMATCH",
150
+ "Cannot verify .qbento source identity: project.json is invalid JSON.",
151
+ );
152
+ }
153
+ if (binding?.version !== 1 || binding.source !== basename(paths.source)) {
154
+ throw new StateError(
155
+ "PROJECT_SOURCE_MISMATCH",
156
+ ".qbento belongs to a different QMD source.",
157
+ );
158
+ }
159
+ return true;
160
+ }
161
+
162
+ /** Require a verified binding before exposing an existing saved project. */
163
+ export async function assertProjectBinding(source: string) {
164
+ const paths = projectPaths(source);
165
+ if (!await readProjectBinding(paths)) {
166
+ throw new StateError(
167
+ "NO_BASELINE",
168
+ "Render this QMD before reading its project.",
169
+ );
170
+ }
171
+ return paths;
172
+ }
173
+
92
174
  export async function withProject<T>(
93
175
  source: string,
94
176
  action: (
@@ -97,6 +179,9 @@ export async function withProject<T>(
97
179
  ) => Promise<T>,
98
180
  ): Promise<T> {
99
181
  const paths = projectPaths(source);
182
+ // Fail closed before creating even a lock when identity was lost. Recheck
183
+ // under the operation lock below to retain protection against concurrent edits.
184
+ await readProjectBinding(paths);
100
185
  await Deno.mkdir(paths.stateDir, { recursive: true });
101
186
  const stateInfo = await Deno.lstat(paths.stateDir);
102
187
  if (stateInfo.isSymlink || !stateInfo.isDirectory) {
@@ -122,23 +207,17 @@ export async function withProject<T>(
122
207
  started: new Date().toISOString(),
123
208
  }),
124
209
  );
125
- const identity = resolve(paths.stateDir, "project.json");
126
- if (await exists(identity)) {
127
- const meta = JSON.parse(await Deno.readTextFile(identity));
128
- if (meta.version !== 1 || meta.source !== basename(paths.source)) {
129
- throw new Error(
130
- "PROJECT_SOURCE_MISMATCH: .qbento belongs to a different source",
131
- );
132
- }
133
- } else {await Deno.writeTextFile(
134
- identity,
210
+ if (!await readProjectBinding(paths)) {
211
+ await Deno.writeTextFile(
212
+ resolve(paths.stateDir, "project.json"),
135
213
  JSON.stringify(
136
214
  { version: 1, source: basename(paths.source) },
137
215
  null,
138
216
  2,
139
217
  ) + "\n",
140
218
  { createNew: true },
141
- );}
219
+ );
220
+ }
142
221
  if (await exists(resolve(paths.stateDir, "render-transaction.json"))) {
143
222
  throw new Error(
144
223
  "RECOVERY_REQUIRED: inspect the interrupted render transaction before continuing",
@@ -715,8 +794,14 @@ export async function saveProject(
715
794
  });
716
795
  }
717
796
  export async function readProject(source: string): Promise<ProjectState> {
718
- const state = await new StateStore(projectPaths(source).stateDir).load();
719
- if (!state) throw new Error("NO_BASELINE: render this project first");
797
+ const paths = await assertProjectBinding(source);
798
+ const state = await new StateStore(paths.stateDir).load();
799
+ if (!state) {
800
+ throw new StateError(
801
+ "NO_BASELINE",
802
+ "Render this QMD before reading its project.",
803
+ );
804
+ }
720
805
  return state;
721
806
  }
722
807
 
@@ -725,7 +810,14 @@ export async function readProjectDiagnostics(
725
810
  source: string,
726
811
  ): Promise<Diagnostic[]> {
727
812
  const paths = projectPaths(source);
813
+ const bound = await readProjectBinding(paths);
728
814
  const state = await new StateStore(paths.stateDir).load();
815
+ if (state && !bound) {
816
+ throw new StateError(
817
+ "NO_BASELINE",
818
+ "Restore the source binding before reading saved diagnostics.",
819
+ );
820
+ }
729
821
  const registry = await loadDesignRegistry(paths.directory);
730
822
  const mapping = scanSource(await Deno.readTextFile(paths.source));
731
823
  const designDiagnostics = missingDesignDiagnostics(mapping, state, registry);
@@ -59,7 +59,7 @@ import {
59
59
  /** Approved bounded text synchronization with a durable, recoverable QMD transaction. */
60
60
  import { verifiedSourceSlide } from "./source-scope.ts";
61
61
  import { basename, dirname, resolve } from "node:path";
62
- import { projectPaths, withProject } from "./project.ts";
62
+ import { assertProjectBinding, projectPaths, withProject } from "./project.ts";
63
63
  import {
64
64
  canonical,
65
65
  clone,
@@ -218,6 +218,14 @@ export interface SyncApprovalEnvelope {
218
218
  export interface SyncOptions {
219
219
  confirmedDeleteIds?: string[];
220
220
  }
221
+ /** A reviewed selection is bound to one saved document revision. */
222
+ export interface SyncSelection {
223
+ version: 1;
224
+ docId: string;
225
+ expectedRevision: number;
226
+ selectedChangeIds: string[];
227
+ confirmedDeleteIds?: string[];
228
+ }
221
229
  export interface SyncItem {
222
230
  changeId: string;
223
231
  target: Change["target"];
@@ -968,6 +976,28 @@ async function buildPlan(
968
976
  }
969
977
  return p;
970
978
  });
979
+ // Fresh execution retires all pending content beneath an approved deletion.
980
+ // A selective plan must never label that content as excluded and then lose it.
981
+ const selectedIds = new Set(ids);
982
+ for (const deletion of selected) {
983
+ if (!["delete-slide", "delete-element"].includes(deletion.kind)) continue;
984
+ const required = state.pending.filter((pending) =>
985
+ !selectedIds.has(pending.id) &&
986
+ pending.target.slideId === deletion.target.slideId &&
987
+ (deletion.kind === "delete-slide" ||
988
+ pending.target.elementId === deletion.target.elementId)
989
+ );
990
+ if (required.length) {
991
+ fail(
992
+ "PARTIAL_DELETE_SELECTION",
993
+ `Deleting this ${
994
+ deletion.kind === "delete-slide" ? "slide" : "element"
995
+ } would retire unselected pending edits. Select those edits together with the deletion, or restore/review them before deleting. Required additional change IDs: ${
996
+ JSON.stringify(required.map((pending) => pending.id))
997
+ }`,
998
+ );
999
+ }
1000
+ }
971
1001
  if (
972
1002
  selected.some((change) =>
973
1003
  change.kind === "delete-element" &&
@@ -2069,6 +2099,7 @@ async function readableSnapshot(source: string) {
2069
2099
  const paths = projectPaths(source);
2070
2100
  await safeDirectory(paths.stateDir);
2071
2101
  await regular(paths.source);
2102
+ await assertProjectBinding(source);
2072
2103
  for (
2073
2104
  const name of [
2074
2105
  "operation.lock",
@@ -2084,17 +2115,6 @@ async function readableSnapshot(source: string) {
2084
2115
  );
2085
2116
  }
2086
2117
  }
2087
- const identity = resolve(paths.stateDir, "project.json");
2088
- if (await present(identity)) {
2089
- await regular(identity);
2090
- const value = JSON.parse(await Deno.readTextFile(identity));
2091
- if (value.version !== 1 || value.source !== basename(paths.source)) {
2092
- fail(
2093
- "PROJECT_SOURCE_MISMATCH",
2094
- "This .qbento state belongs to another source.",
2095
- );
2096
- }
2097
- }
2098
2118
  const store = new StateStore(paths.stateDir), state = await store.load();
2099
2119
  if (!state) {
2100
2120
  fail("NO_BASELINE", "Render and save the project before synchronizing.");
@@ -2121,6 +2141,120 @@ export async function previewSync(
2121
2141
  const { paths, state, text } = await readableSnapshot(source);
2122
2142
  return buildPlan(paths.source, text, state, selectedChangeIds, options);
2123
2143
  }
2144
+ const MAX_SYNC_SELECTION_BYTES = 1024 * 1024;
2145
+ function validateSyncSelection(value: unknown): asserts value is SyncSelection {
2146
+ if (
2147
+ typeof value !== "object" || value === null || Array.isArray(value) ||
2148
+ ![Object.prototype, null].includes(Object.getPrototypeOf(value))
2149
+ ) fail("INVALID_SELECTION", "Expected a sync selection object.");
2150
+ const request = value as Record<string, unknown>;
2151
+ const allowed = [
2152
+ "version",
2153
+ "docId",
2154
+ "expectedRevision",
2155
+ "selectedChangeIds",
2156
+ "confirmedDeleteIds",
2157
+ ];
2158
+ const identity = (id: unknown): id is string =>
2159
+ typeof id === "string" && id.length > 0 && id.length <= 4096 &&
2160
+ id.trim() === id &&
2161
+ Array.from(id).every((character) => {
2162
+ const code = character.charCodeAt(0);
2163
+ return code >= 32 && code !== 127;
2164
+ });
2165
+ const ids = (list: unknown): list is string[] =>
2166
+ Array.isArray(list) && list.length <= 10000 && list.every(identity) &&
2167
+ new Set(list).size === list.length;
2168
+ if (
2169
+ Object.keys(request).some((key) => !allowed.includes(key)) ||
2170
+ request.version !== 1 || !identity(request.docId) ||
2171
+ !Number.isSafeInteger(request.expectedRevision) ||
2172
+ (request.expectedRevision as number) < 0 ||
2173
+ !ids(request.selectedChangeIds) ||
2174
+ (request.confirmedDeleteIds !== undefined &&
2175
+ !ids(request.confirmedDeleteIds))
2176
+ ) {
2177
+ fail(
2178
+ "INVALID_SELECTION",
2179
+ "Expected version 1, docId, a nonnegative expectedRevision and unique selectedChangeIds; only confirmedDeleteIds is optional.",
2180
+ );
2181
+ }
2182
+ if (
2183
+ new TextEncoder().encode(JSON.stringify(request)).length >
2184
+ MAX_SYNC_SELECTION_BYTES
2185
+ ) {
2186
+ fail("SYNC_INPUT_TOO_LARGE", "Selection exceeds the 1 MiB limit.");
2187
+ }
2188
+ }
2189
+ /** Read-only public preview; selection never bypasses normal source/plan checks. */
2190
+ export async function previewSelectedSync(
2191
+ source: string,
2192
+ request: unknown,
2193
+ ): Promise<SyncPlan> {
2194
+ validateSyncSelection(request);
2195
+ const { paths, state, text } = await readableSnapshot(source);
2196
+ if (state.docId !== request.docId) {
2197
+ fail(
2198
+ "UNRELATED_DOCUMENT",
2199
+ "Get the current document before selecting changes.",
2200
+ );
2201
+ }
2202
+ if (state.revision !== request.expectedRevision) {
2203
+ fail("STALE_REVISION", "Get the current deck before selecting changes.");
2204
+ }
2205
+ return buildPlan(paths.source, text, state, request.selectedChangeIds, {
2206
+ confirmedDeleteIds: request.confirmedDeleteIds,
2207
+ });
2208
+ }
2209
+ /** Bounded UTF-8 JSON input; refuses links and nonregular files before opening. */
2210
+ export async function readSyncSelectionFile(path: string): Promise<unknown> {
2211
+ const info = await Deno.lstat(path);
2212
+ if (!info.isFile || info.isSymlink) {
2213
+ fail(
2214
+ "SYNC_INPUT_FILE",
2215
+ "Selection input must be a regular JSON file, not a symlink.",
2216
+ );
2217
+ }
2218
+ const file = await Deno.open(path, { read: true });
2219
+ try {
2220
+ const opened = await file.stat();
2221
+ if (!opened.isFile) {
2222
+ fail("SYNC_INPUT_FILE", "Selection input must be a regular JSON file.");
2223
+ }
2224
+ if (opened.size > MAX_SYNC_SELECTION_BYTES) {
2225
+ fail("SYNC_INPUT_TOO_LARGE", "Selection exceeds the 1 MiB limit.");
2226
+ }
2227
+ const chunks: Uint8Array[] = [];
2228
+ let size = 0;
2229
+ while (true) {
2230
+ const chunk = new Uint8Array(16 * 1024), read = await file.read(chunk);
2231
+ if (read === null) break;
2232
+ size += read;
2233
+ if (size > MAX_SYNC_SELECTION_BYTES) {
2234
+ fail("SYNC_INPUT_TOO_LARGE", "Selection exceeds the 1 MiB limit.");
2235
+ }
2236
+ chunks.push(chunk.subarray(0, read));
2237
+ }
2238
+ const bytes = new Uint8Array(size);
2239
+ let offset = 0;
2240
+ for (const chunk of chunks) {
2241
+ bytes.set(chunk, offset);
2242
+ offset += chunk.length;
2243
+ }
2244
+ try {
2245
+ return JSON.parse(
2246
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
2247
+ );
2248
+ } catch {
2249
+ fail(
2250
+ "SYNC_INVALID_JSON",
2251
+ "Selection input must contain valid UTF-8 JSON.",
2252
+ );
2253
+ }
2254
+ } finally {
2255
+ file.close();
2256
+ }
2257
+ }
2124
2258
  /** Explicit approved action; leaves pending semantic edits until a future render confirms them. */
2125
2259
  export async function applySync(
2126
2260
  source: string,
@@ -8,6 +8,7 @@ let state, bridge, syncPlan, reconcilePlan, busy = false, previewGeneration = 0;
8
8
  let diagnosticGeneration = 0, projectDiagnostics = [], renderDiagnostics = [];
9
9
  let renderDiagnosticSnapshot;
10
10
  let connectedApi, connectedSnapshot;
11
+ let syncReviewSnapshot, syncPlanSelection;
11
12
  async function api(path, body) {
12
13
  const response = await fetch("/api/" + path, {
13
14
  method: body === undefined ? "GET" : "POST",
@@ -293,7 +294,10 @@ function showPatches(plan) {
293
294
  section.append(title, message);
294
295
  $("patches").append(section);
295
296
  }
296
- if (plan.structure || plan.identities || plan.graphs || plan.languages) {
297
+ if (
298
+ plan.structure || plan.identities || plan.graphs || plan.languages ||
299
+ plan.attributes || plan.notes || plan.annotations?.patches.length
300
+ ) {
297
301
  const section = document.createElement("section"),
298
302
  title = document.createElement("h3"),
299
303
  result = document.createElement("pre");
@@ -303,35 +307,103 @@ function showPatches(plan) {
303
307
  $("patches").append(section);
304
308
  }
305
309
  }
310
+ function syncChangeLabel(change) {
311
+ const kinds = {
312
+ "delete-element": "Eliminar elemento",
313
+ "delete-slide": "Eliminar diapositiva",
314
+ "create-element": "Crear elemento",
315
+ "create-slide": "Crear diapositiva",
316
+ "slide-order": "Orden de diapositivas",
317
+ };
318
+ const fields = {
319
+ html: "Texto",
320
+ notes: "Notas",
321
+ content: "Contenido",
322
+ grammarName: "Lenguaje del código",
323
+ };
324
+ const property = change.path.join(".");
325
+ const action = kinds[change.kind] || fields[property] || property || "Cambio";
326
+ return `${change.target.slideId || "Presentación"}${
327
+ change.target.elementId ? " · " + change.target.elementId : ""
328
+ } · ${action}`;
329
+ }
330
+ function currentSyncSelection() {
331
+ return [...$("sync-selection").querySelectorAll("input")]
332
+ .filter((box) => box.checked).map((box) => box.dataset.change);
333
+ }
334
+ function sameSyncSelection(left, right) {
335
+ return left.length === right.length && left.every((id) => right.includes(id));
336
+ }
337
+ function requiredSyncDeleteBoxes(selected) {
338
+ return [...$("delete-confirmations").querySelectorAll("input")].filter(
339
+ (box) => {
340
+ const required = !syncReviewSnapshot ||
341
+ selected.includes(box.dataset.change);
342
+ box.disabled = !required;
343
+ return required;
344
+ },
345
+ );
346
+ }
306
347
  async function prepareSyncPreview() {
307
348
  if (busy) return;
308
349
  const generation = ++previewGeneration;
309
350
  syncPlan = undefined;
310
351
  $("apply").disabled = true;
311
352
  $("patches").replaceChildren();
312
- const boxes = [...$("delete-confirmations").querySelectorAll("input")];
353
+ const selected = currentSyncSelection();
354
+ if (syncReviewSnapshot) {
355
+ const excluded = syncReviewSnapshot.pending.filter((change) =>
356
+ !selected.includes(change.id)
357
+ );
358
+ $("sync-selection-summary").textContent =
359
+ `${selected.length} seleccionados; ${excluded.length} excluidos y conservados pendientes.` +
360
+ (excluded.length
361
+ ? ` Excluidos: ${excluded.map(syncChangeLabel).join("; ")}.`
362
+ : "");
363
+ }
364
+ const boxes = requiredSyncDeleteBoxes(selected);
313
365
  if (boxes.some((box) => !box.checked)) {
314
366
  $("sync-note").textContent =
315
- "Confirma cada eliminación para preparar la propuesta. Puedes restaurar elementos desde Comparar con QMD y diapositivas desde Diapositivas.";
367
+ "Confirma cada eliminación seleccionada para preparar la propuesta o exclúyela. Puedes restaurar elementos desde Comparar con QMD y diapositivas desde Diapositivas.";
316
368
  return;
317
369
  }
318
370
  $("sync-note").textContent = "Preparando los cambios propuestos…";
319
371
  try {
320
372
  const plan = await api("sync/preview", {
321
- confirmedDeleteIds: boxes.map((box) => box.dataset.change),
373
+ ...(syncReviewSnapshot
374
+ ? {
375
+ version: 1,
376
+ docId: syncReviewSnapshot.docId,
377
+ expectedRevision: syncReviewSnapshot.revision,
378
+ selectedChangeIds: selected,
379
+ }
380
+ : {}),
381
+ confirmedDeleteIds: boxes.filter((box) => box.checked)
382
+ .map((box) => box.dataset.change),
322
383
  });
323
384
  if (generation !== previewGeneration) return;
324
- if (plan.stateRevision !== state.revision) {
385
+ if (
386
+ plan.stateRevision !== state.revision ||
387
+ (syncReviewSnapshot && syncReviewSnapshot.docId !== state.docId)
388
+ ) {
325
389
  throw new Error(
326
390
  "El proyecto cambió mientras se preparaba la revisión. Recarga antes de sincronizar.",
327
391
  );
328
392
  }
393
+ if (
394
+ syncReviewSnapshot && !sameSyncSelection(plan.selectedChangeIds, selected)
395
+ ) {
396
+ throw new Error(
397
+ "La propuesta no coincide con la selección revisada. Prepara una revisión nueva.",
398
+ );
399
+ }
329
400
  syncPlan = plan;
401
+ syncPlanSelection = selected;
330
402
  showPatches(syncPlan);
331
403
  const hasChanges = syncPlan.patch.patches.length ||
332
404
  syncPlan.identities?.patches.length ||
333
405
  syncPlan.attributes?.patches.length || syncPlan.notes?.patches.length ||
334
- syncPlan.graphs?.patches.length ||
406
+ syncPlan.languages?.patches.length || syncPlan.graphs?.patches.length ||
335
407
  syncPlan.annotations?.patches.length ||
336
408
  syncPlan.structure;
337
409
  $("sync-note").textContent = hasChanges
@@ -356,9 +428,25 @@ $("review").onclick = async () => {
356
428
  "Otra pestaña ha cambiado el proyecto. Conserva tus cambios y recarga antes de sincronizar.",
357
429
  );
358
430
  }
431
+ previewGeneration++;
359
432
  syncPlan = undefined;
433
+ syncReviewSnapshot = { ...saved, docId: state.docId };
360
434
  $("apply").disabled = true;
361
435
  $("patches").replaceChildren();
436
+ $("sync-selection").replaceChildren();
437
+ for (const change of saved.pending) {
438
+ const label = document.createElement("label"),
439
+ checkbox = document.createElement("input");
440
+ checkbox.type = "checkbox";
441
+ checkbox.checked = true;
442
+ checkbox.dataset.change = change.id;
443
+ checkbox.onchange = prepareSyncPreview;
444
+ label.append(
445
+ checkbox,
446
+ document.createTextNode(` ${syncChangeLabel(change)}`),
447
+ );
448
+ $("sync-selection").append(label);
449
+ }
362
450
  $("delete-confirmations").replaceChildren();
363
451
  for (
364
452
  const change of saved.pending.filter((item) =>
@@ -389,25 +477,37 @@ $("review").onclick = async () => {
389
477
  };
390
478
  $("apply").onclick = async () => {
391
479
  if (!syncPlan || busy) return;
480
+ const selected = currentSyncSelection();
481
+ if (requiredSyncDeleteBoxes(selected).some((box) => !box.checked)) return;
392
482
  if (
393
- [...$("delete-confirmations").querySelectorAll("input")].some((box) =>
394
- !box.checked
483
+ syncReviewSnapshot && (
484
+ syncReviewSnapshot.docId !== state.docId ||
485
+ syncReviewSnapshot.revision !== state.revision ||
486
+ !sameSyncSelection(selected, syncPlanSelection) ||
487
+ !sameSyncSelection(selected, syncPlan.selectedChangeIds)
395
488
  )
396
- ) return;
489
+ ) {
490
+ syncPlan = undefined;
491
+ $("apply").disabled = true;
492
+ $("sync-note").textContent =
493
+ "La selección cambió. Prepara una revisión nueva antes de aplicar.";
494
+ return;
495
+ }
397
496
  setBusy(true);
398
497
  $("busy").textContent = "Aplicando los cambios revisados al QMD…";
399
498
  $("sync-note").textContent = $("busy").textContent;
400
499
  $("apply").disabled = true;
401
500
  $("close").disabled = true;
402
501
  $("delete-confirmations").inert = true;
502
+ $("sync-selection").inert = true;
403
503
  previewGeneration++;
404
504
  try {
405
505
  const receipt = await api("sync/apply", syncPlan);
406
506
  state.revision = receipt.revision;
407
507
  syncPlan = undefined;
408
508
  $("sync-note").textContent = receipt.annotations?.diagnostics.length
409
- ? "Cambios semánticos aplicados al QMD; las anotaciones opcionales se han omitido por los diagnósticos revisados. Recalcula para actualizar la presentación."
410
- : "Cambios aplicados al QMD. Recalcula para actualizar la presentación.";
509
+ ? "Cambios semánticos aplicados al QMD; las anotaciones opcionales se han omitido por los diagnósticos revisados. Los cambios aplicados seguirán pendientes hasta Recalcular y verificar el resultado."
510
+ : "Cambios aplicados al QMD. Seguirán pendientes hasta Recalcular y verificar el resultado. Los cambios excluidos se conservan.";
411
511
  status("QMD sincronizado; listo para recalcular");
412
512
  } catch (error) {
413
513
  $("sync-note").textContent = error.message;
@@ -415,6 +515,7 @@ $("apply").onclick = async () => {
415
515
  setBusy(false);
416
516
  $("close").disabled = false;
417
517
  $("delete-confirmations").inert = false;
518
+ $("sync-selection").inert = false;
418
519
  $("apply").disabled = !syncPlan;
419
520
  }
420
521
  };
@@ -427,6 +528,7 @@ $("review-dialog").addEventListener("cancel", (event) => {
427
528
  $("review-dialog").addEventListener("close", () => {
428
529
  previewGeneration++;
429
530
  syncPlan = undefined;
531
+ syncReviewSnapshot = undefined;
430
532
  });
431
533
  function reloadEditor() {
432
534
  $("editor").contentWindow.__qbentoManagedReload = true;
@@ -170,6 +170,11 @@
170
170
  background: #156f65;
171
171
  color: white;
172
172
  }
173
+ #sync-selection label,
174
+ #delete-confirmations label {
175
+ display: block;
176
+ margin: 10px 0;
177
+ }
173
178
  #sync-note {
174
179
  line-height: 1.5;
175
180
  }
@@ -209,7 +214,7 @@
209
214
  role="status">Recalculando. Tus cambios están guardados.</div>
210
215
  </main>
211
216
  <dialog
212
- id="review-dialog"><h2>Revisar cambios hacia QMD</h2><p id="sync-note">Guardar conserva el proyecto; sincronizar aplica únicamente los cambios que revisas aquí.</p><div id="delete-confirmations"></div><div id="patches"></div><details><summary>Origen y detalle de los cambios</summary><div id="changes"></div></details><button id="apply" disabled>Aplicar estos cambios a QMD</button> <button id="close">Cerrar</button></dialog>
217
+ id="review-dialog"><h2>Revisar cambios hacia QMD</h2><p id="sync-note">Guardar conserva el proyecto; sincronizar aplica únicamente los cambios que revisas aquí.</p><p>Selecciona los cambios que quieres sincronizar. Los cambios excluidos se conservan pendientes. Los miembros de un componente deben revisarse juntos.</p><div id="sync-selection"></div><p id="sync-selection-summary" role="status"></p><div id="delete-confirmations"></div><div id="patches"></div><details><summary>Origen y detalle de los cambios</summary><div id="changes"></div></details><button id="apply" disabled>Aplicar estos cambios a QMD</button> <button id="close">Cerrar</button></dialog>
213
218
  <dialog
214
219
  id="slides-dialog"><h2>Diapositivas</h2><p>Ocultar conserva el contenido y lo omite en la presentación y el PDF. Marcar para eliminar guarda una eliminación pendiente; después debes revisarla y confirmarla para modificar QMD. Puedes restaurarla antes de sincronizar.</p><p id="slides-note" role="status"></p><div id="slide-list"></div><button id="slides-close">Cerrar</button></dialog>
215
220
  <dialog id="layouts-dialog" aria-labelledby="layouts-title">
@@ -20,7 +20,7 @@ import {
20
20
  renderProject,
21
21
  saveProject,
22
22
  } from "../core/project.ts";
23
- import { applySync, previewSync } from "../core/sync.ts";
23
+ import { applySync, previewSelectedSync, previewSync } from "../core/sync.ts";
24
24
  import { applyReconcile, previewReconcile } from "../core/reconcile.ts";
25
25
  import {
26
26
  applySlideAction,
@@ -80,6 +80,8 @@ export async function readStudioBody(
80
80
  }
81
81
 
82
82
  export async function startStudio(source: string, port = 0) {
83
+ // Refuse a mismatched QMD before exposing any editor or state endpoint.
84
+ await readProject(source);
83
85
  const token = crypto.randomUUID() + crypto.randomUUID();
84
86
  const shell = await loadBentoShell();
85
87
  const bridge = await Deno.readTextFile(
@@ -315,11 +317,37 @@ export async function startStudio(source: string, port = 0) {
315
317
  }
316
318
  const text = await readStudioBody(req);
317
319
  if (text === null) return respond('{"error":"Plan too large"}', 413);
318
- const { confirmedDeleteIds } = JSON.parse(text);
320
+ const payload = JSON.parse(text);
321
+ if (
322
+ !payload || typeof payload !== "object" || Array.isArray(payload)
323
+ ) {
324
+ throw new StateError(
325
+ "INVALID_SELECTION",
326
+ "Expected a sync preview object.",
327
+ );
328
+ }
329
+ const selected = [
330
+ "version",
331
+ "docId",
332
+ "expectedRevision",
333
+ "selectedChangeIds",
334
+ ]
335
+ .some((key) => Object.hasOwn(payload, key));
336
+ if (
337
+ !selected &&
338
+ Object.keys(payload).some((key) => key !== "confirmedDeleteIds")
339
+ ) {
340
+ throw new StateError(
341
+ "INVALID_SELECTION",
342
+ "Unknown sync preview field.",
343
+ );
344
+ }
319
345
  return respond(JSON.stringify(
320
- await previewSync(source, undefined, {
321
- confirmedDeleteIds,
322
- }),
346
+ selected
347
+ ? await previewSelectedSync(source, payload)
348
+ : await previewSync(source, undefined, {
349
+ confirmedDeleteIds: payload.confirmedDeleteIds,
350
+ }),
323
351
  ));
324
352
  }
325
353
  if (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brianmsm/quarto-bento",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Offline Quarto and Bento authoring with retained design and reviewed QMD synchronization",
5
5
  "type": "module",
6
6
  "bin": {