@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 +12 -5
- package/distribution/PACKAGING.md +3 -3
- package/distribution/manifest.json +17 -17
- package/distribution/runtime/src/cli.ts +22 -9
- package/distribution/runtime/src/core/agent.ts +1 -20
- package/distribution/runtime/src/core/project.ts +105 -13
- package/distribution/runtime/src/core/sync.ts +146 -12
- package/distribution/runtime/src/studio/client.js +113 -11
- package/distribution/runtime/src/studio/index.html +6 -1
- package/distribution/runtime/src/studio/server.ts +33 -5
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
38
|
-
@brianmsm/quarto-bento@0.1.
|
|
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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": "
|
|
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":
|
|
26
|
-
"sha256": "
|
|
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":
|
|
104
|
-
"sha256": "
|
|
103
|
+
"bytes": 8288,
|
|
104
|
+
"sha256": "8228662370e715dc1f8a0a35557534494bb9ae6aa5199a620116531cc5717ff1",
|
|
105
105
|
"executable": false
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"path": "runtime/src/core/agent.ts",
|
|
109
|
-
"bytes":
|
|
110
|
-
"sha256": "
|
|
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":
|
|
194
|
-
"sha256": "
|
|
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":
|
|
224
|
-
"sha256": "
|
|
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":
|
|
584
|
-
"sha256": "
|
|
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":
|
|
596
|
-
"sha256": "
|
|
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":
|
|
620
|
-
"sha256": "
|
|
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 {
|
|
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 (
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
719
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
394
|
-
|
|
483
|
+
syncReviewSnapshot && (
|
|
484
|
+
syncReviewSnapshot.docId !== state.docId ||
|
|
485
|
+
syncReviewSnapshot.revision !== state.revision ||
|
|
486
|
+
!sameSyncSelection(selected, syncPlanSelection) ||
|
|
487
|
+
!sameSyncSelection(selected, syncPlan.selectedChangeIds)
|
|
395
488
|
)
|
|
396
|
-
)
|
|
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.
|
|
410
|
-
: "Cambios aplicados al QMD.
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
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 (
|