dsh-claude-slides 2.5.0 → 2.6.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.
- package/README.md +1 -1
- package/docs/DETAIL.md +1 -1
- package/docs/DETAIL.zh.md +1 -1
- package/lib/client.js +1 -1
- package/lib/index.js +30 -6
- package/package.json +1 -1
- package/skills/claude-slides/SKILL.md +14 -3
- package/skills/claude-slides/scripts/artifact.mjs +2 -1
- package/skills/claude-slides/scripts/editor/assets/{index-DULUou9H.js → index-ExkfOJag.js} +280 -175
- package/skills/claude-slides/scripts/editor/assets/{woff2-decoder-B0JzUtP5.js → woff2-decoder-DHpUx46X.js} +1 -1
- package/skills/claude-slides/scripts/editor/index.html +1 -1
- package/skills/claude-slides/scripts/editor/node/compiler.cjs +149 -9
- package/skills/claude-slides/scripts/editor/share-ui.js +97 -2
- package/skills/claude-slides/scripts/server.mjs +1331 -102
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// claude-slides: local editor server (prebuilt; do not edit).
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import fs8 from "node:fs";
|
|
5
|
+
import path7 from "node:path";
|
|
6
6
|
|
|
7
|
-
import { createHash as
|
|
8
|
-
import
|
|
7
|
+
import { createHash as createHash3, randomBytes as randomBytes2, randomUUID } from "node:crypto";
|
|
8
|
+
import fs7 from "node:fs";
|
|
9
9
|
import fsp2 from "node:fs/promises";
|
|
10
10
|
import http from "node:http";
|
|
11
|
-
import
|
|
11
|
+
import path6 from "node:path";
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
13
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
@@ -281,6 +281,7 @@ async function updateComments(root, change) {
|
|
|
281
281
|
var AGENT_GRACE_MS = 6e4;
|
|
282
282
|
var AGENT_BUSY_MS = 30 * 6e4;
|
|
283
283
|
var REQUEST_TTL_MS = 30 * 6e4;
|
|
284
|
+
var REQUESTS_KEPT = 32;
|
|
284
285
|
var WAIT_DEFAULT_S = 300;
|
|
285
286
|
var WAIT_MAX_S = 3600;
|
|
286
287
|
var PRESENCE_CHECK_MS = 5e3;
|
|
@@ -345,6 +346,7 @@ function createCommentsService(options) {
|
|
|
345
346
|
const waiters = [];
|
|
346
347
|
const hookRunning = /* @__PURE__ */ new Set();
|
|
347
348
|
const requests = [];
|
|
349
|
+
const requestFates = /* @__PURE__ */ new Map();
|
|
348
350
|
let lastDoc = emptyDoc();
|
|
349
351
|
let lastWaitEnded = 0;
|
|
350
352
|
let lastSent = "";
|
|
@@ -357,7 +359,7 @@ function createCommentsService(options) {
|
|
|
357
359
|
const listening = waiters.length > 0;
|
|
358
360
|
const busy = lastDoc.threads.some(
|
|
359
361
|
(t) => t.status === "open" && t.agent?.status === "delivered" && at - Date.parse(t.agent.deliveredAt ?? t.agent.sentAt) < AGENT_BUSY_MS
|
|
360
|
-
);
|
|
362
|
+
) || [...requestFates.values()].some((f) => f.state === "working" && at - f.at < AGENT_BUSY_MS);
|
|
361
363
|
const hook = Boolean(agentHook);
|
|
362
364
|
return { available: listening || busy || hook || at - lastWaitEnded < AGENT_GRACE_MS, listening, busy, hook };
|
|
363
365
|
}
|
|
@@ -406,10 +408,16 @@ data: ${JSON.stringify(snap)}
|
|
|
406
408
|
if (!waiter.response.writableEnded) sendJson(waiter.response, 200, body);
|
|
407
409
|
lastWaitEnded = Date.now();
|
|
408
410
|
}
|
|
411
|
+
function setRequestState(id, state) {
|
|
412
|
+
requestFates.delete(id);
|
|
413
|
+
requestFates.set(id, { state, at: Date.now() });
|
|
414
|
+
while (requestFates.size > REQUESTS_KEPT) requestFates.delete(requestFates.keys().next().value);
|
|
415
|
+
}
|
|
409
416
|
function dropStaleRequests() {
|
|
410
417
|
const oldest = Date.now() - REQUEST_TTL_MS;
|
|
411
418
|
while (requests.length > 0 && Date.parse(requests[0].sentAt) < oldest) {
|
|
412
419
|
const dropped = requests.shift();
|
|
420
|
+
setRequestState(dropped.id, "over");
|
|
413
421
|
log(`comments: dropped request ${dropped.id}: no agent took it in ${REQUEST_TTL_MS / 6e4} minutes`);
|
|
414
422
|
}
|
|
415
423
|
}
|
|
@@ -436,6 +444,7 @@ data: ${JSON.stringify(snap)}
|
|
|
436
444
|
return;
|
|
437
445
|
}
|
|
438
446
|
finishWaiter(waiter, { threads: views, ...handed.length > 0 ? { requests: handed } : {} });
|
|
447
|
+
for (const request of handed) setRequestState(request.id, "working");
|
|
439
448
|
log(`comments: sent to the agent (wait): ${[...views, ...handed].map((v) => v.id).join(", ")}`);
|
|
440
449
|
await broadcast();
|
|
441
450
|
return;
|
|
@@ -483,6 +492,7 @@ data: ${JSON.stringify(snap)}
|
|
|
483
492
|
}
|
|
484
493
|
for (const request of requests.splice(0)) {
|
|
485
494
|
hookRunning.add(request.id);
|
|
495
|
+
setRequestState(request.id, "working");
|
|
486
496
|
const child = spawn(command, {
|
|
487
497
|
shell: true,
|
|
488
498
|
cwd: root,
|
|
@@ -503,6 +513,7 @@ data: ${JSON.stringify(snap)}
|
|
|
503
513
|
settled = true;
|
|
504
514
|
hookRunning.delete(request.id);
|
|
505
515
|
requests.unshift(request);
|
|
516
|
+
setRequestState(request.id, "queued");
|
|
506
517
|
log(`comments: the agent hook ${why} for ${request.id}; the request stays waiting${stderr ? `: ${stderr.trim()}` : ""}`);
|
|
507
518
|
};
|
|
508
519
|
child.on("error", (error) => failed(`failed to start (${error.message})`));
|
|
@@ -568,16 +579,26 @@ data: ${JSON.stringify(snap)}
|
|
|
568
579
|
sendJson(response, 201, { thread: created });
|
|
569
580
|
await afterChange(Boolean(input.send));
|
|
570
581
|
}
|
|
571
|
-
|
|
572
|
-
const input = readAgentRequest(await readJson(request));
|
|
573
|
-
if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
|
|
582
|
+
function enqueue(input) {
|
|
574
583
|
if (!presence().available) throw new CommentsError("AGENT_UNAVAILABLE", "no agent is waiting: copy the request for the chat instead", 409);
|
|
575
584
|
const queued = { id: newId("r"), ...input, sentAt: now() };
|
|
576
585
|
requests.push(queued);
|
|
586
|
+
setRequestState(queued.id, "queued");
|
|
577
587
|
log(`comments: request ${queued.id}${input.label ? ` (${input.label})` : ""} for the agent`);
|
|
588
|
+
return queued;
|
|
589
|
+
}
|
|
590
|
+
async function ask(request, response) {
|
|
591
|
+
const input = readAgentRequest(await readJson(request));
|
|
592
|
+
if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
|
|
593
|
+
const queued = enqueue(input);
|
|
578
594
|
sendJson(response, 202, { request: queued });
|
|
579
595
|
await deliver();
|
|
580
596
|
}
|
|
597
|
+
async function sendRequest(input) {
|
|
598
|
+
const queued = enqueue(input);
|
|
599
|
+
await deliver();
|
|
600
|
+
return queued;
|
|
601
|
+
}
|
|
581
602
|
async function events(request, response, viewer) {
|
|
582
603
|
response.writeHead(200, {
|
|
583
604
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
@@ -600,6 +621,7 @@ data: ${JSON.stringify(snap)}
|
|
|
600
621
|
const seconds = Number.isFinite(asked) && asked > 0 ? Math.min(asked, WAIT_MAX_S) : WAIT_DEFAULT_S;
|
|
601
622
|
const waiter = { response, done: false };
|
|
602
623
|
waiters.push(waiter);
|
|
624
|
+
for (const [id, fate] of requestFates) if (fate.state === "working") setRequestState(id, "over");
|
|
603
625
|
waiter.timer = setTimeout(() => {
|
|
604
626
|
finishWaiter(waiter, { threads: [] });
|
|
605
627
|
void broadcast();
|
|
@@ -738,7 +760,12 @@ data: ${JSON.stringify(snap)}
|
|
|
738
760
|
for (const client of clients.keys()) client.end();
|
|
739
761
|
clients.clear();
|
|
740
762
|
}
|
|
741
|
-
|
|
763
|
+
function requestStates() {
|
|
764
|
+
const states = {};
|
|
765
|
+
for (const [id, fate] of requestFates) if (fate.state !== "over") states[id] = fate.state;
|
|
766
|
+
return states;
|
|
767
|
+
}
|
|
768
|
+
return { handle, presence, deliver, sendRequest, requestStates, close };
|
|
742
769
|
}
|
|
743
770
|
|
|
744
771
|
import fs3 from "node:fs";
|
|
@@ -802,10 +829,14 @@ var PortsTaken = class extends Error {
|
|
|
802
829
|
this.to = to;
|
|
803
830
|
}
|
|
804
831
|
};
|
|
832
|
+
var isBenchmarkRange = (address) => /^198\.1[89]\./.test(address);
|
|
805
833
|
function lanAddresses() {
|
|
806
834
|
const out = [];
|
|
807
835
|
for (const list of Object.values(os2.networkInterfaces())) {
|
|
808
|
-
for (const entry of list ?? [])
|
|
836
|
+
for (const entry of list ?? []) {
|
|
837
|
+
if (entry.family !== "IPv4" || entry.internal || entry.address.startsWith("169.254.") || isBenchmarkRange(entry.address)) continue;
|
|
838
|
+
out.push(entry.address);
|
|
839
|
+
}
|
|
809
840
|
}
|
|
810
841
|
return [...new Set(out)];
|
|
811
842
|
}
|
|
@@ -816,18 +847,38 @@ function answers(host, port) {
|
|
|
816
847
|
socket.destroy();
|
|
817
848
|
resolve(taken);
|
|
818
849
|
};
|
|
819
|
-
socket.setTimeout(300, () => done(
|
|
850
|
+
socket.setTimeout(300, () => done(false));
|
|
820
851
|
socket.once("connect", () => done(true));
|
|
821
|
-
socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
|
|
852
|
+
socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "EAFNOSUPPORT", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
|
|
822
853
|
});
|
|
823
854
|
}
|
|
855
|
+
async function answersAnyPort(address) {
|
|
856
|
+
const server2 = net.createServer();
|
|
857
|
+
let port;
|
|
858
|
+
try {
|
|
859
|
+
port = await new Promise((resolve, reject) => {
|
|
860
|
+
server2.once("error", reject);
|
|
861
|
+
server2.listen(0, address, () => {
|
|
862
|
+
const bound = server2.address();
|
|
863
|
+
resolve(typeof bound === "object" && bound ? bound.port : 0);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
} catch {
|
|
867
|
+
return false;
|
|
868
|
+
} finally {
|
|
869
|
+
await new Promise((resolve) => server2.close(() => resolve()));
|
|
870
|
+
}
|
|
871
|
+
return port > 0 && answers(address, port);
|
|
872
|
+
}
|
|
824
873
|
async function listenFree(server2, from, host, first) {
|
|
825
874
|
if (from === 0) return listen(server2, 0, host);
|
|
826
875
|
const to = Math.min(from + PORT_TRIES - 1, 65535);
|
|
827
876
|
const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
828
877
|
const ports = first ? [first, ...range.filter((port) => port !== first)] : range;
|
|
829
878
|
const wildcard = host === "0.0.0.0" || host === "::";
|
|
830
|
-
const
|
|
879
|
+
const lan = lanAddresses().filter((address) => address !== host);
|
|
880
|
+
const answering = await Promise.all(lan.map(answersAnyPort));
|
|
881
|
+
const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lan.filter((_, i) => !answering[i])])];
|
|
831
882
|
for (const port of ports) {
|
|
832
883
|
if ((await Promise.all(probes.map((address) => answers(address, port)))).some(Boolean)) continue;
|
|
833
884
|
try {
|
|
@@ -902,21 +953,569 @@ function sendFile(request, response, file, headers) {
|
|
|
902
953
|
}
|
|
903
954
|
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
904
955
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
956
|
+
var MAX_EDIT = 2e3;
|
|
957
|
+
function editScript(a, b) {
|
|
958
|
+
const n = a.length;
|
|
959
|
+
const m = b.length;
|
|
960
|
+
const limit = Math.min(n + m, MAX_EDIT);
|
|
961
|
+
const offset = limit + 1;
|
|
962
|
+
const v = new Int32Array(2 * limit + 3);
|
|
963
|
+
const trace = [];
|
|
964
|
+
let found = -1;
|
|
965
|
+
for (let d = 0; d <= limit; d++) {
|
|
966
|
+
trace.push(v.slice(offset - d - 1, offset + d + 2));
|
|
967
|
+
for (let k = -d; k <= d; k += 2) {
|
|
968
|
+
let x2 = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1;
|
|
969
|
+
let y2 = x2 - k;
|
|
970
|
+
while (x2 < n && y2 < m && a[x2] === b[y2]) {
|
|
971
|
+
x2++;
|
|
972
|
+
y2++;
|
|
973
|
+
}
|
|
974
|
+
v[offset + k] = x2;
|
|
975
|
+
if (x2 >= n && y2 >= m) {
|
|
976
|
+
found = d;
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (found >= 0) break;
|
|
981
|
+
}
|
|
982
|
+
if (found < 0) return null;
|
|
983
|
+
const ops = [];
|
|
984
|
+
let x = n;
|
|
985
|
+
let y = m;
|
|
986
|
+
for (let d = found; d > 0; d--) {
|
|
987
|
+
const prev = trace[d];
|
|
988
|
+
const at = (k2) => prev[k2 + d + 1];
|
|
989
|
+
const k = x - y;
|
|
990
|
+
const prevK = k === -d || k !== d && at(k - 1) < at(k + 1) ? k + 1 : k - 1;
|
|
991
|
+
const prevX = at(prevK);
|
|
992
|
+
const prevY = prevX - prevK;
|
|
993
|
+
while (x > prevX && y > prevY) {
|
|
994
|
+
ops.push("=");
|
|
995
|
+
x--;
|
|
996
|
+
y--;
|
|
997
|
+
}
|
|
998
|
+
ops.push(x === prevX ? "+" : "-");
|
|
999
|
+
x = prevX;
|
|
1000
|
+
y = prevY;
|
|
1001
|
+
}
|
|
1002
|
+
while (x > 0 && y > 0) {
|
|
1003
|
+
ops.push("=");
|
|
1004
|
+
x--;
|
|
1005
|
+
y--;
|
|
1006
|
+
}
|
|
1007
|
+
return ops.reverse();
|
|
1008
|
+
}
|
|
1009
|
+
function hunksOf(base, side, mine) {
|
|
1010
|
+
const ops = editScript(base, side);
|
|
1011
|
+
if (!ops) return null;
|
|
1012
|
+
const out = [];
|
|
1013
|
+
let open = null;
|
|
1014
|
+
let i = 0;
|
|
1015
|
+
let j = 0;
|
|
1016
|
+
for (const op of ops) {
|
|
1017
|
+
if (op === "=") {
|
|
1018
|
+
open = null;
|
|
1019
|
+
i++;
|
|
1020
|
+
j++;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (!open) out.push(open = { start: i, end: i, lines: [], mine });
|
|
1024
|
+
if (op === "-") open.end = ++i;
|
|
1025
|
+
else open.lines.push(side[j++]);
|
|
1026
|
+
}
|
|
1027
|
+
return out.flatMap(
|
|
1028
|
+
(h) => h.end - h.start > 1 && h.lines.length === h.end - h.start ? h.lines.flatMap((line, k) => {
|
|
1029
|
+
const at = h.start + k;
|
|
1030
|
+
return line === base[at] ? [] : [{ start: at, end: at + 1, lines: [line], mine }];
|
|
1031
|
+
}) : [h]
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
function applyHunks(base, start, end, hunks) {
|
|
1035
|
+
const out = [];
|
|
1036
|
+
let at = start;
|
|
1037
|
+
for (const h of hunks) {
|
|
1038
|
+
out.push(...base.slice(at, h.start), ...h.lines);
|
|
1039
|
+
at = h.end;
|
|
1040
|
+
}
|
|
1041
|
+
out.push(...base.slice(at, end));
|
|
1042
|
+
return out;
|
|
1043
|
+
}
|
|
1044
|
+
function mergeText(base, mine, current, at, conflicts) {
|
|
1045
|
+
const lines = base.split("\n");
|
|
1046
|
+
const ours = hunksOf(lines, mine.split("\n"), true);
|
|
1047
|
+
const theirs = ours && hunksOf(lines, current.split("\n"), false);
|
|
1048
|
+
if (!ours || !theirs) return null;
|
|
1049
|
+
const all = [...ours, ...theirs].sort((x, y) => x.start - y.start || x.end - y.end);
|
|
1050
|
+
const out = [];
|
|
1051
|
+
const lost = [];
|
|
1052
|
+
let done = 0;
|
|
1053
|
+
for (let i = 0; i < all.length; ) {
|
|
1054
|
+
const region = [all[i++]];
|
|
1055
|
+
const start = region[0].start;
|
|
1056
|
+
let end = region[0].end;
|
|
1057
|
+
for (; i < all.length; i++) {
|
|
1058
|
+
const h = all[i];
|
|
1059
|
+
if (!(h.start < end || h.start === h.end && start === end && h.start === start)) break;
|
|
1060
|
+
region.push(h);
|
|
1061
|
+
end = Math.max(end, h.end);
|
|
1062
|
+
}
|
|
1063
|
+
out.push(...lines.slice(done, start));
|
|
1064
|
+
const mineLines = applyHunks(lines, start, end, region.filter((h) => h.mine));
|
|
1065
|
+
if (region.every((h) => h.mine === region[0].mine)) out.push(...applyHunks(lines, start, end, region));
|
|
1066
|
+
else {
|
|
1067
|
+
const theirLines = applyHunks(lines, start, end, region.filter((h) => !h.mine));
|
|
1068
|
+
if (mineLines.join("\n") !== theirLines.join("\n")) lost.push(`${at}@${start + 1}`);
|
|
1069
|
+
out.push(...mineLines);
|
|
1070
|
+
}
|
|
1071
|
+
done = end;
|
|
1072
|
+
}
|
|
1073
|
+
out.push(...lines.slice(done));
|
|
1074
|
+
conflicts.push(...lost);
|
|
1075
|
+
return out.join("\n");
|
|
1076
|
+
}
|
|
909
1077
|
|
|
910
|
-
|
|
1078
|
+
var utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
1079
|
+
function mergeTextBytes(base, mine, current) {
|
|
1080
|
+
const text = (bytes) => bytes.includes(0) ? null : utf8.decode(bytes);
|
|
1081
|
+
try {
|
|
1082
|
+
const [b, m, c] = [text(base), text(mine), text(current)];
|
|
1083
|
+
if (b === null || m === null || c === null) return null;
|
|
1084
|
+
const merged = mergeText(b, m, c, ".", []);
|
|
1085
|
+
return merged === null ? null : Buffer.from(merged, "utf8");
|
|
1086
|
+
} catch {
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
911
1092
|
import fs4 from "node:fs";
|
|
912
1093
|
import path3 from "node:path";
|
|
1094
|
+
var HISTORY_DIR = "history";
|
|
1095
|
+
var INDEX = "index.json";
|
|
1096
|
+
var OBJECTS = "objects";
|
|
1097
|
+
var IDLE_MS = 3e4;
|
|
1098
|
+
var SPAN_MS = 5 * 6e4;
|
|
1099
|
+
var MAX_VERSIONS = 500;
|
|
1100
|
+
var MAX_NOTICES = 50;
|
|
1101
|
+
var NOTICE_DAYS = 7;
|
|
1102
|
+
var AGENT = { by: "agent", user: { id: "agent", name: "Agent" } };
|
|
1103
|
+
var SYSTEM = { by: "system", user: { id: "system", name: "" } };
|
|
1104
|
+
var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
1105
|
+
var SHA = /^[0-9a-f]{64}$/;
|
|
1106
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1107
|
+
function writeAtomic2(file, data) {
|
|
1108
|
+
fs4.mkdirSync(path3.dirname(file), { recursive: true });
|
|
1109
|
+
const tmp = path3.join(path3.dirname(file), `.${path3.basename(file)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
1110
|
+
try {
|
|
1111
|
+
fs4.writeFileSync(tmp, data);
|
|
1112
|
+
fs4.renameSync(tmp, file);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
fs4.rmSync(tmp, { force: true });
|
|
1115
|
+
throw error;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
var emptyIndex = () => ({ format: 1, next: 1, base: {}, versions: [], notices: [] });
|
|
1119
|
+
function readUser(value) {
|
|
1120
|
+
return isRecord2(value) && typeof value.id === "string" && typeof value.name === "string" ? { id: value.id, name: value.name } : null;
|
|
1121
|
+
}
|
|
1122
|
+
var isAuthor = (value) => value === "human" || value === "agent" || value === "system";
|
|
1123
|
+
function parseIndex(raw) {
|
|
1124
|
+
const index = emptyIndex();
|
|
1125
|
+
if (!isRecord2(raw)) return index;
|
|
1126
|
+
if (isRecord2(raw.base)) {
|
|
1127
|
+
for (const [p, s] of Object.entries(raw.base)) if (typeof s === "string" && SHA.test(s)) index.base[p] = s;
|
|
1128
|
+
}
|
|
1129
|
+
if (Array.isArray(raw.versions)) {
|
|
1130
|
+
for (const v of raw.versions) {
|
|
1131
|
+
if (!isRecord2(v) || typeof v.n !== "number" || !isAuthor(v.by) || typeof v.at !== "string" || !isRecord2(v.changes)) continue;
|
|
1132
|
+
const user = readUser(v.user);
|
|
1133
|
+
if (!user) continue;
|
|
1134
|
+
const changes = {};
|
|
1135
|
+
for (const [p, s] of Object.entries(v.changes)) if (s === null || typeof s === "string" && SHA.test(s)) changes[p] = s;
|
|
1136
|
+
const version = { n: v.n, first: typeof v.first === "string" ? v.first : v.at, at: v.at, by: v.by, user, changes };
|
|
1137
|
+
if (typeof v.role === "string") version.role = v.role;
|
|
1138
|
+
if (typeof v.restoredFrom === "number") version.restoredFrom = v.restoredFrom;
|
|
1139
|
+
if (version.restoredFrom !== void 0 && v.restoredPart === true) version.restoredPart = true;
|
|
1140
|
+
if (Array.isArray(v.overwrote)) {
|
|
1141
|
+
const list = [];
|
|
1142
|
+
for (const o of v.overwrote) {
|
|
1143
|
+
const who = isRecord2(o) ? readUser(o.user) : null;
|
|
1144
|
+
if (!isRecord2(o) || !who || typeof o.path !== "string" || o.lost !== null && typeof o.lost !== "string" || typeof o.n !== "number" || !isAuthor(o.by)) continue;
|
|
1145
|
+
list.push({
|
|
1146
|
+
path: o.path,
|
|
1147
|
+
lost: o.lost,
|
|
1148
|
+
n: o.n,
|
|
1149
|
+
by: o.by,
|
|
1150
|
+
user: who,
|
|
1151
|
+
...o.soft === true ? { soft: true } : {},
|
|
1152
|
+
...o.chosen === true ? { chosen: true } : {},
|
|
1153
|
+
...typeof o.base === "string" && SHA.test(o.base) ? { base: o.base } : {}
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
if (list.length) version.overwrote = list;
|
|
1157
|
+
}
|
|
1158
|
+
index.versions.push(version);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
index.versions.sort((a, b) => a.n - b.n);
|
|
1162
|
+
const last = index.versions.at(-1);
|
|
1163
|
+
index.next = Math.max(typeof raw.next === "number" ? raw.next : 1, (last?.n ?? 0) + 1);
|
|
1164
|
+
if (Array.isArray(raw.notices)) {
|
|
1165
|
+
for (const n of raw.notices) {
|
|
1166
|
+
if (!isRecord2(n) || typeof n.id !== "string" || typeof n.to !== "string" || typeof n.path !== "string" || typeof n.n !== "number") continue;
|
|
1167
|
+
if (n.kind !== "overwritten" && n.kind !== "overwrote") continue;
|
|
1168
|
+
const by = readUser(n.by);
|
|
1169
|
+
const lost = isRecord2(n.lost) ? n.lost : null;
|
|
1170
|
+
const lostUser = lost ? readUser(lost.user) : null;
|
|
1171
|
+
if (!by || !lost || !lostUser || lost.sha !== null && typeof lost.sha !== "string" || typeof lost.n !== "number" || !isAuthor(lost.by)) continue;
|
|
1172
|
+
index.notices.push({
|
|
1173
|
+
id: n.id,
|
|
1174
|
+
to: n.to,
|
|
1175
|
+
at: typeof n.at === "string" ? n.at : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1176
|
+
kind: n.kind,
|
|
1177
|
+
path: n.path,
|
|
1178
|
+
n: n.n,
|
|
1179
|
+
by,
|
|
1180
|
+
byAuthor: isAuthor(n.byAuthor) ? n.byAuthor : "human",
|
|
1181
|
+
lost: { sha: lost.sha, n: lost.n, by: lost.by, user: lostUser },
|
|
1182
|
+
...n.soft === true ? { soft: true } : {},
|
|
1183
|
+
...typeof n.base === "string" && SHA.test(n.base) ? { base: n.base } : {}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return index;
|
|
1188
|
+
}
|
|
1189
|
+
var History = class {
|
|
1190
|
+
dir;
|
|
1191
|
+
index;
|
|
1192
|
+
/** path → sha: what the files hold after the newest version. */
|
|
1193
|
+
headState;
|
|
1194
|
+
constructor(root) {
|
|
1195
|
+
this.dir = path3.join(root, HISTORY_DIR);
|
|
1196
|
+
let raw = null;
|
|
1197
|
+
try {
|
|
1198
|
+
raw = JSON.parse(fs4.readFileSync(path3.join(this.dir, INDEX), "utf8"));
|
|
1199
|
+
} catch {
|
|
1200
|
+
}
|
|
1201
|
+
this.index = parseIndex(raw);
|
|
1202
|
+
this.headState = this.fold(Infinity);
|
|
1203
|
+
this.index.notices = this.index.notices.filter((n) => (this.headState.get(n.path) ?? null) !== n.lost.sha);
|
|
1204
|
+
}
|
|
1205
|
+
get empty() {
|
|
1206
|
+
return this.index.versions.length === 0 && Object.keys(this.index.base).length === 0;
|
|
1207
|
+
}
|
|
1208
|
+
/** What the files hold now, as far as the history knows (path → sha). */
|
|
1209
|
+
head() {
|
|
1210
|
+
return this.headState;
|
|
1211
|
+
}
|
|
1212
|
+
versions() {
|
|
1213
|
+
return this.index.versions;
|
|
1214
|
+
}
|
|
1215
|
+
version(n) {
|
|
1216
|
+
return this.index.versions.find((v) => v.n === n);
|
|
1217
|
+
}
|
|
1218
|
+
/** The files after the versions numbered up to `upto` (the base and every version at or below it). */
|
|
1219
|
+
fold(upto) {
|
|
1220
|
+
const state = new Map(Object.entries(this.index.base));
|
|
1221
|
+
for (const version of this.index.versions) {
|
|
1222
|
+
if (version.n > upto) break;
|
|
1223
|
+
for (const [p, s] of Object.entries(version.changes)) {
|
|
1224
|
+
if (s === null) state.delete(p);
|
|
1225
|
+
else state.set(p, s);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return state;
|
|
1229
|
+
}
|
|
1230
|
+
/** The files right after version `n`; null when it is not kept. */
|
|
1231
|
+
stateAt(n) {
|
|
1232
|
+
return this.version(n) ? this.fold(n) : null;
|
|
1233
|
+
}
|
|
1234
|
+
/** The files right before version `n`. */
|
|
1235
|
+
stateBefore(n) {
|
|
1236
|
+
return this.fold(n - 1);
|
|
1237
|
+
}
|
|
1238
|
+
/** The content with this sha, from the objects. */
|
|
1239
|
+
read(sha) {
|
|
1240
|
+
if (!SHA.test(sha)) return null;
|
|
1241
|
+
try {
|
|
1242
|
+
return fs4.readFileSync(path3.join(this.dir, OBJECTS, sha));
|
|
1243
|
+
} catch {
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/** Keeps a content that no version holds (an overwrite's `base`); its sha. */
|
|
1248
|
+
put(bytes) {
|
|
1249
|
+
const sha = sha256(bytes);
|
|
1250
|
+
this.putObject(sha, bytes);
|
|
1251
|
+
return sha;
|
|
1252
|
+
}
|
|
1253
|
+
size(sha) {
|
|
1254
|
+
if (!SHA.test(sha)) return null;
|
|
1255
|
+
try {
|
|
1256
|
+
return fs4.statSync(path3.join(this.dir, OBJECTS, sha)).size;
|
|
1257
|
+
} catch {
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/** The newest version that changed `path`, with what it left there. */
|
|
1262
|
+
lastChange(p) {
|
|
1263
|
+
for (let i = this.index.versions.length - 1; i >= 0; i--) {
|
|
1264
|
+
const version = this.index.versions[i];
|
|
1265
|
+
if (p in version.changes) return { version, sha: version.changes[p] };
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Records changes: path → its content now (null: deleted). Those equal to the head are left out; nothing left, no
|
|
1271
|
+
* version (null). The rest join the open version of the same person, or start a new one.
|
|
1272
|
+
*/
|
|
1273
|
+
record(changes, who, extra = {}, now = /* @__PURE__ */ new Date()) {
|
|
1274
|
+
const changed = {};
|
|
1275
|
+
for (const [p, bytes] of changes) {
|
|
1276
|
+
const sha = bytes === null ? null : sha256(bytes);
|
|
1277
|
+
if ((this.headState.get(p) ?? null) === sha) continue;
|
|
1278
|
+
if (bytes !== null && sha !== null) this.putObject(sha, bytes);
|
|
1279
|
+
changed[p] = sha;
|
|
1280
|
+
}
|
|
1281
|
+
if (Object.keys(changed).length === 0) return null;
|
|
1282
|
+
const iso = now.toISOString();
|
|
1283
|
+
const last = this.index.versions.at(-1);
|
|
1284
|
+
const joins = !!last && who.by !== "system" && last.by === who.by && last.user.id === who.user.id && last.restoredFrom === void 0 && extra.restoredFrom === void 0 && now.getTime() - Date.parse(last.at) < IDLE_MS && now.getTime() - Date.parse(last.first) < SPAN_MS;
|
|
1285
|
+
let version;
|
|
1286
|
+
if (joins) {
|
|
1287
|
+
version = last;
|
|
1288
|
+
version.at = iso;
|
|
1289
|
+
Object.assign(version.changes, changed);
|
|
1290
|
+
if (who.user.name) version.user = { ...who.user };
|
|
1291
|
+
} else {
|
|
1292
|
+
version = { n: this.index.next++, first: iso, at: iso, by: who.by, user: { ...who.user }, changes: changed };
|
|
1293
|
+
if (who.role) version.role = who.role;
|
|
1294
|
+
if (extra.restoredFrom !== void 0) version.restoredFrom = extra.restoredFrom;
|
|
1295
|
+
if (extra.restoredFrom !== void 0 && extra.restoredPart) version.restoredPart = true;
|
|
1296
|
+
this.index.versions.push(version);
|
|
1297
|
+
}
|
|
1298
|
+
for (const [p, s] of Object.entries(changed)) {
|
|
1299
|
+
if (s === null) this.headState.delete(p);
|
|
1300
|
+
else this.headState.set(p, s);
|
|
1301
|
+
}
|
|
1302
|
+
this.index.notices = this.index.notices.filter(
|
|
1303
|
+
(n) => !(n.path in changed && (changed[n.path] === n.lost.sha || extra.restoredFrom !== void 0 && n.to === who.user.id))
|
|
1304
|
+
);
|
|
1305
|
+
if (extra.overwrote?.length) {
|
|
1306
|
+
version.overwrote = [...(version.overwrote ?? []).filter((o) => !extra.overwrote.some((x) => x.path === o.path)), ...extra.overwrote];
|
|
1307
|
+
this.notify(version, who, extra.overwrote, now);
|
|
1308
|
+
}
|
|
1309
|
+
this.prune(now);
|
|
1310
|
+
this.save();
|
|
1311
|
+
return version;
|
|
1312
|
+
}
|
|
1313
|
+
/** Notices for an overwrite: to whoever lost their change (the agent's go to the owner), and a hard one's saver unless they chose it. */
|
|
1314
|
+
notify(version, who, overwrote, now) {
|
|
1315
|
+
const at = now.toISOString();
|
|
1316
|
+
for (const o of overwrote) {
|
|
1317
|
+
const lost = { sha: o.lost, n: o.n, by: o.by, user: o.user };
|
|
1318
|
+
const victim = o.by === "agent" ? "local" : o.user.id;
|
|
1319
|
+
const fields = {
|
|
1320
|
+
at,
|
|
1321
|
+
path: o.path,
|
|
1322
|
+
n: version.n,
|
|
1323
|
+
by: { ...who.user },
|
|
1324
|
+
byAuthor: who.by,
|
|
1325
|
+
lost,
|
|
1326
|
+
...o.soft ? { soft: true } : {},
|
|
1327
|
+
...o.base ? { base: o.base } : {}
|
|
1328
|
+
};
|
|
1329
|
+
const same = (n) => n.path === o.path && n.n === version.n;
|
|
1330
|
+
this.index.notices = this.index.notices.filter((n) => !same(n));
|
|
1331
|
+
if (victim !== who.user.id) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: victim, kind: "overwritten", ...fields });
|
|
1332
|
+
if (!o.soft && !o.chosen) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: who.user.id, kind: "overwrote", ...fields });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
/** The notices for this person, newest first. */
|
|
1336
|
+
noticesFor(userId) {
|
|
1337
|
+
return this.index.notices.filter((n) => n.to === userId).reverse();
|
|
1338
|
+
}
|
|
1339
|
+
notice(id) {
|
|
1340
|
+
return this.index.notices.find((n) => n.id === id);
|
|
1341
|
+
}
|
|
1342
|
+
/** Dismisses one of this person's notices; false when they have none by that id. */
|
|
1343
|
+
dismiss(userId, id) {
|
|
1344
|
+
const before = this.index.notices.length;
|
|
1345
|
+
this.index.notices = this.index.notices.filter((n) => !(n.id === id && n.to === userId));
|
|
1346
|
+
if (this.index.notices.length === before) return false;
|
|
1347
|
+
this.save();
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* What to write to bring version `n` back: path → content (null: delete), for `paths` (default: every file it or the
|
|
1352
|
+
* head holds), only where the head differs. null when the version is not kept or a content is missing.
|
|
1353
|
+
*/
|
|
1354
|
+
restoreTo(n, paths, tracked = () => true) {
|
|
1355
|
+
const target = this.stateAt(n);
|
|
1356
|
+
if (!target) return null;
|
|
1357
|
+
const out = /* @__PURE__ */ new Map();
|
|
1358
|
+
const all = paths ?? [.../* @__PURE__ */ new Set([...target.keys(), ...this.headState.keys()])];
|
|
1359
|
+
for (const p of all) {
|
|
1360
|
+
if (!tracked(p)) continue;
|
|
1361
|
+
const want = target.get(p) ?? null;
|
|
1362
|
+
if ((this.headState.get(p) ?? null) === want) continue;
|
|
1363
|
+
if (want === null) {
|
|
1364
|
+
out.set(p, null);
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const bytes = this.read(want);
|
|
1368
|
+
if (!bytes) return null;
|
|
1369
|
+
out.set(p, bytes);
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
/** Each version's changes as added, edited or deleted (what the file held before it decides). */
|
|
1374
|
+
fileChanges() {
|
|
1375
|
+
const out = /* @__PURE__ */ new Map();
|
|
1376
|
+
const state = new Map(Object.entries(this.index.base));
|
|
1377
|
+
for (const version of this.index.versions) {
|
|
1378
|
+
const list = [];
|
|
1379
|
+
for (const [p, s] of Object.entries(version.changes)) {
|
|
1380
|
+
const had = state.has(p);
|
|
1381
|
+
list.push({ path: p, op: s === null ? "deleted" : had ? "edited" : "added" });
|
|
1382
|
+
if (s === null) state.delete(p);
|
|
1383
|
+
else state.set(p, s);
|
|
1384
|
+
}
|
|
1385
|
+
out.set(version.n, list);
|
|
1386
|
+
}
|
|
1387
|
+
return out;
|
|
1388
|
+
}
|
|
1389
|
+
putObject(sha, bytes) {
|
|
1390
|
+
const file = path3.join(this.dir, OBJECTS, sha);
|
|
1391
|
+
if (fs4.existsSync(file)) return;
|
|
1392
|
+
writeAtomic2(file, bytes);
|
|
1393
|
+
}
|
|
1394
|
+
/** Past MAX_VERSIONS the oldest fold into the base; old notices go; contents no one holds are removed. */
|
|
1395
|
+
prune(now) {
|
|
1396
|
+
let folded = false;
|
|
1397
|
+
while (this.index.versions.length > MAX_VERSIONS) {
|
|
1398
|
+
const old = this.index.versions.shift();
|
|
1399
|
+
for (const [p, s] of Object.entries(old.changes)) {
|
|
1400
|
+
if (s === null) delete this.index.base[p];
|
|
1401
|
+
else this.index.base[p] = s;
|
|
1402
|
+
}
|
|
1403
|
+
folded = true;
|
|
1404
|
+
}
|
|
1405
|
+
const oldest = now.getTime() - NOTICE_DAYS * 864e5;
|
|
1406
|
+
const kept = this.index.versions[0]?.n ?? Infinity;
|
|
1407
|
+
const perPerson = /* @__PURE__ */ new Map();
|
|
1408
|
+
this.index.notices = this.index.notices.filter((n) => Date.parse(n.at) >= oldest && n.lost.n >= kept).reverse().filter((n) => {
|
|
1409
|
+
const count = (perPerson.get(n.to) ?? 0) + 1;
|
|
1410
|
+
perPerson.set(n.to, count);
|
|
1411
|
+
return count <= MAX_NOTICES;
|
|
1412
|
+
}).reverse();
|
|
1413
|
+
if (folded) this.collect();
|
|
1414
|
+
}
|
|
1415
|
+
/** Removes the objects that neither the base, a version nor a notice holds (with the base an overwrite was made on). */
|
|
1416
|
+
collect() {
|
|
1417
|
+
const keep = new Set(Object.values(this.index.base));
|
|
1418
|
+
for (const version of this.index.versions) {
|
|
1419
|
+
for (const s of Object.values(version.changes)) if (s) keep.add(s);
|
|
1420
|
+
for (const o of version.overwrote ?? []) if (o.base) keep.add(o.base);
|
|
1421
|
+
}
|
|
1422
|
+
for (const notice of this.index.notices) {
|
|
1423
|
+
if (notice.lost.sha) keep.add(notice.lost.sha);
|
|
1424
|
+
if (notice.base) keep.add(notice.base);
|
|
1425
|
+
}
|
|
1426
|
+
let names;
|
|
1427
|
+
try {
|
|
1428
|
+
names = fs4.readdirSync(path3.join(this.dir, OBJECTS));
|
|
1429
|
+
} catch {
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
for (const name of names) if (SHA.test(name) && !keep.has(name)) fs4.rmSync(path3.join(this.dir, OBJECTS, name), { force: true });
|
|
1433
|
+
}
|
|
1434
|
+
save() {
|
|
1435
|
+
writeAtomic2(path3.join(this.dir, INDEX), `${JSON.stringify(this.index)}
|
|
1436
|
+
`);
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
var MAX_DIFF_BYTES = 1024 * 1024;
|
|
1440
|
+
var CONTEXT = 3;
|
|
1441
|
+
var MAX_DIFF_LINES = 3e3;
|
|
1442
|
+
var MAX_LINE = 2e3;
|
|
1443
|
+
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1444
|
+
function textOf(bytes) {
|
|
1445
|
+
if (bytes === null) return null;
|
|
1446
|
+
if (bytes.includes(0)) return void 0;
|
|
1447
|
+
try {
|
|
1448
|
+
return decoder.decode(bytes);
|
|
1449
|
+
} catch {
|
|
1450
|
+
return void 0;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
var splitLines = (text) => text === null || text === "" ? [] : text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
|
|
1454
|
+
function diffContents(before, after) {
|
|
1455
|
+
if ((before?.length ?? 0) > MAX_DIFF_BYTES || (after?.length ?? 0) > MAX_DIFF_BYTES) return { tooLarge: true, added: 0, removed: 0, lines: [] };
|
|
1456
|
+
const a = textOf(before);
|
|
1457
|
+
const b = textOf(after);
|
|
1458
|
+
if (a === void 0 || b === void 0) return { binary: true, added: 0, removed: 0, lines: [] };
|
|
1459
|
+
const left = splitLines(a);
|
|
1460
|
+
const right = splitLines(b);
|
|
1461
|
+
let head = 0;
|
|
1462
|
+
while (head < left.length && head < right.length && left[head] === right[head]) head++;
|
|
1463
|
+
let tail = 0;
|
|
1464
|
+
while (tail < left.length - head && tail < right.length - head && left[left.length - 1 - tail] === right[right.length - 1 - tail]) tail++;
|
|
1465
|
+
const middle = editScript(left.slice(head, left.length - tail), right.slice(head, right.length - tail));
|
|
1466
|
+
if (!middle) return { tooLarge: true, added: 0, removed: 0, lines: [] };
|
|
1467
|
+
const ops = [...Array(head).fill("="), ...middle, ...Array(tail).fill("=")];
|
|
1468
|
+
const all = [];
|
|
1469
|
+
let i = 0;
|
|
1470
|
+
let j = 0;
|
|
1471
|
+
let added = 0;
|
|
1472
|
+
let removed = 0;
|
|
1473
|
+
const cut = (s) => s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}\u2026` : s;
|
|
1474
|
+
for (const op of ops) {
|
|
1475
|
+
if (op === "=") all.push([" ", cut(right[j++])]), i++;
|
|
1476
|
+
else if (op === "-") all.push(["-", cut(left[i++])]), removed++;
|
|
1477
|
+
else all.push(["+", cut(right[j++])]), added++;
|
|
1478
|
+
}
|
|
1479
|
+
const keep = new Uint8Array(all.length);
|
|
1480
|
+
all.forEach(([kind], at) => {
|
|
1481
|
+
if (kind === " ") return;
|
|
1482
|
+
for (let x = Math.max(0, at - CONTEXT); x <= Math.min(all.length - 1, at + CONTEXT); x++) keep[x] = 1;
|
|
1483
|
+
});
|
|
1484
|
+
const lines = [];
|
|
1485
|
+
let skipped = 0;
|
|
1486
|
+
let truncated = false;
|
|
1487
|
+
for (let at = 0; at < all.length; at++) {
|
|
1488
|
+
if (!keep[at]) {
|
|
1489
|
+
skipped++;
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (skipped) lines.push(["~", String(skipped)]);
|
|
1493
|
+
skipped = 0;
|
|
1494
|
+
if (lines.length >= MAX_DIFF_LINES) {
|
|
1495
|
+
truncated = true;
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
lines.push(all[at]);
|
|
1499
|
+
}
|
|
1500
|
+
if (skipped && !truncated) lines.push(["~", String(skipped)]);
|
|
1501
|
+
return { added, removed, lines, ...truncated ? { truncated: true } : {} };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1505
|
+
import fs6 from "node:fs";
|
|
1506
|
+
import os3 from "node:os";
|
|
1507
|
+
import path5 from "node:path";
|
|
1508
|
+
|
|
1509
|
+
import crypto from "node:crypto";
|
|
1510
|
+
import fs5 from "node:fs";
|
|
1511
|
+
import path4 from "node:path";
|
|
913
1512
|
var GUEST_ROLES = ["editor", "commenter", "viewer"];
|
|
914
1513
|
var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
|
|
915
1514
|
var SHARING_FILE = ".sharing.json";
|
|
916
1515
|
var MAX_GUESTS = 200;
|
|
917
1516
|
var MAX_NAME = 40;
|
|
918
1517
|
var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
|
|
919
|
-
var
|
|
1518
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
920
1519
|
function fresh() {
|
|
921
1520
|
return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
|
|
922
1521
|
}
|
|
@@ -936,29 +1535,29 @@ var Sharing = class _Sharing {
|
|
|
936
1535
|
file;
|
|
937
1536
|
state;
|
|
938
1537
|
constructor(root) {
|
|
939
|
-
this.file =
|
|
1538
|
+
this.file = path4.join(root, SHARING_FILE);
|
|
940
1539
|
this.state = _Sharing.read(this.file);
|
|
941
1540
|
}
|
|
942
1541
|
static read(file) {
|
|
943
1542
|
let raw;
|
|
944
1543
|
try {
|
|
945
|
-
raw = JSON.parse(
|
|
1544
|
+
raw = JSON.parse(fs5.readFileSync(file, "utf8"));
|
|
946
1545
|
} catch {
|
|
947
1546
|
return fresh();
|
|
948
1547
|
}
|
|
949
1548
|
const state = fresh();
|
|
950
|
-
if (!
|
|
1549
|
+
if (!isRecord3(raw)) return state;
|
|
951
1550
|
state.lan = raw.lan === true;
|
|
952
1551
|
if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
|
|
953
|
-
if (
|
|
1552
|
+
if (isRecord3(raw.links)) {
|
|
954
1553
|
for (const role of GUEST_ROLES) {
|
|
955
1554
|
const link = raw.links[role];
|
|
956
|
-
if (
|
|
1555
|
+
if (isRecord3(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
957
1556
|
}
|
|
958
1557
|
}
|
|
959
1558
|
if (Array.isArray(raw.guests)) {
|
|
960
1559
|
for (const g of raw.guests) {
|
|
961
|
-
if (!
|
|
1560
|
+
if (!isRecord3(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
962
1561
|
state.guests.push({
|
|
963
1562
|
id: g.id,
|
|
964
1563
|
secret: g.secret,
|
|
@@ -974,9 +1573,9 @@ var Sharing = class _Sharing {
|
|
|
974
1573
|
}
|
|
975
1574
|
save() {
|
|
976
1575
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
977
|
-
|
|
1576
|
+
fs5.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
|
|
978
1577
|
`, { mode: 384 });
|
|
979
|
-
|
|
1578
|
+
fs5.renameSync(tmp, this.file);
|
|
980
1579
|
}
|
|
981
1580
|
/** The role whose link has this token, while that link is on. */
|
|
982
1581
|
linkRole(tokenGiven) {
|
|
@@ -1066,7 +1665,7 @@ function commentsViewer(viewer) {
|
|
|
1066
1665
|
admin: viewer.role === "owner"
|
|
1067
1666
|
};
|
|
1068
1667
|
}
|
|
1069
|
-
var
|
|
1668
|
+
var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1070
1669
|
var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1071
1670
|
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
1072
1671
|
function ownHosts(host, port) {
|
|
@@ -1081,7 +1680,7 @@ function localOwner() {
|
|
|
1081
1680
|
}
|
|
1082
1681
|
return { id: "local", name, email: `${name}@localhost` };
|
|
1083
1682
|
}
|
|
1084
|
-
var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${
|
|
1683
|
+
var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash2("sha256").update(path5.resolve(root)).digest("hex").slice(0, 12)}`;
|
|
1085
1684
|
var ShareHost = class {
|
|
1086
1685
|
sharing;
|
|
1087
1686
|
options;
|
|
@@ -1240,13 +1839,13 @@ var ShareHost = class {
|
|
|
1240
1839
|
}
|
|
1241
1840
|
if (method === "GET" && p === "/__share/me") return sendJson2(response, 200, this.me(viewer)), true;
|
|
1242
1841
|
if (method === "GET" && p === "/__share/boot.js") {
|
|
1243
|
-
const body = this.bootScript(viewer);
|
|
1842
|
+
const body = this.bootScript(viewer, new URL(request.url ?? "/", "http://local").searchParams);
|
|
1244
1843
|
response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
|
|
1245
1844
|
return true;
|
|
1246
1845
|
}
|
|
1247
1846
|
if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
|
|
1248
1847
|
if (method === "GET" && p === "/__share/ui.js") {
|
|
1249
|
-
|
|
1848
|
+
fs6.stat(this.options.uiFile, (error, stat) => {
|
|
1250
1849
|
if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
|
|
1251
1850
|
sendFile(request, response, this.options.uiFile, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": stat.size, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
1252
1851
|
});
|
|
@@ -1257,24 +1856,27 @@ var ShareHost = class {
|
|
|
1257
1856
|
sendJson2(response, 404, { code: "not_found", message: "Not found" });
|
|
1258
1857
|
return true;
|
|
1259
1858
|
}
|
|
1260
|
-
/**
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). `query`
|
|
1861
|
+
* ("?…") goes to the boot script, for `globals`.
|
|
1862
|
+
*/
|
|
1863
|
+
headTags(_viewer, query = "") {
|
|
1864
|
+
let tags = `<script src="/__share/boot.js${query.replace(/[<>"']/g, "").replace(/&/g, "&")}"></script>`;
|
|
1865
|
+
if (fs6.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
|
|
1264
1866
|
return tags;
|
|
1265
1867
|
}
|
|
1266
1868
|
/** `html` with the head tags first in its <head>; null when it has none. */
|
|
1267
|
-
injectHead(html, viewer) {
|
|
1869
|
+
injectHead(html, viewer, query = "") {
|
|
1268
1870
|
const at = html.search(/<head(\s[^>]*)?>/i);
|
|
1269
1871
|
if (at < 0) return null;
|
|
1270
1872
|
const end = html.indexOf(">", at) + 1;
|
|
1271
|
-
return html.slice(0, end) + this.headTags(viewer) + html.slice(end);
|
|
1873
|
+
return html.slice(0, end) + this.headTags(viewer, query) + html.slice(end);
|
|
1272
1874
|
}
|
|
1273
1875
|
/** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
|
|
1274
|
-
bootScript(viewer) {
|
|
1876
|
+
bootScript(viewer, query = new URLSearchParams()) {
|
|
1275
1877
|
const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
|
|
1276
1878
|
let js = `${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
|
|
1277
|
-
for (const [name, value] of Object.entries(this.options.globals?.(viewer) ?? {})) js += `window[${script(name)}]=${script(value)};`;
|
|
1879
|
+
for (const [name, value] of Object.entries(this.options.globals?.(viewer, query) ?? {})) js += `window[${script(name)}]=${script(value)};`;
|
|
1278
1880
|
if (viewer.guest) js += GUEST_WATCH;
|
|
1279
1881
|
return js;
|
|
1280
1882
|
}
|
|
@@ -1411,7 +2013,7 @@ var ShareHost = class {
|
|
|
1411
2013
|
} catch {
|
|
1412
2014
|
return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
|
|
1413
2015
|
}
|
|
1414
|
-
if (!
|
|
2016
|
+
if (!isRecord4(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
|
|
1415
2017
|
try {
|
|
1416
2018
|
return sendJson2(response, 200, await this.share(action));
|
|
1417
2019
|
} catch (error) {
|
|
@@ -1471,6 +2073,333 @@ var ZH = (noun) => ({
|
|
|
1471
2073
|
ownerHere: `\u4F60\u5728\u5206\u4EAB${noun.zhThis}\u7684\u7535\u8111\u4E0A\u3002\u4EE5\u6240\u6709\u8005\u8EAB\u4EFD\u6253\u5F00\uFF1A`
|
|
1472
2074
|
});
|
|
1473
2075
|
|
|
2076
|
+
var whoOf = (viewer) => ({ by: "human", user: { id: viewer.user.id, name: viewer.user.name }, role: viewer.role });
|
|
2077
|
+
var MAX_BODY = 64 << 10;
|
|
2078
|
+
var HistoryHost = class {
|
|
2079
|
+
history;
|
|
2080
|
+
options;
|
|
2081
|
+
constructor(options) {
|
|
2082
|
+
this.options = options;
|
|
2083
|
+
this.history = options.history;
|
|
2084
|
+
}
|
|
2085
|
+
/** What the page's boot script tells the panel (window.__baoyuHistory); `preview` on a version's preview page. */
|
|
2086
|
+
bootGlobal(viewer, preview) {
|
|
2087
|
+
const global = { base: this.options.base, canRestore: canEditAs(viewer.role), me: viewer.user.id };
|
|
2088
|
+
const version = preview === void 0 ? void 0 : this.history.version(preview);
|
|
2089
|
+
if (version) {
|
|
2090
|
+
const newest = this.history.versions().at(-1);
|
|
2091
|
+
global.preview = { n: version.n, at: version.at, by: version.by, name: version.user.name, current: version === newest, current_url: this.options.base };
|
|
2092
|
+
}
|
|
2093
|
+
return global;
|
|
2094
|
+
}
|
|
2095
|
+
notices(viewer, label) {
|
|
2096
|
+
const head = this.history.head();
|
|
2097
|
+
const gone = (n) => !head.has(n.path) && this.history.version(n.n)?.changes[n.path] === null;
|
|
2098
|
+
return this.history.noticesFor(viewer.user.id).map((n) => noticeView(n, label, this.history.version(n.lost.n)?.at, gone(n)));
|
|
2099
|
+
}
|
|
2100
|
+
list(viewer) {
|
|
2101
|
+
const label = this.options.labels();
|
|
2102
|
+
const versions = this.history.versions();
|
|
2103
|
+
const changes = this.history.fileChanges();
|
|
2104
|
+
const newest = versions.at(-1);
|
|
2105
|
+
const rows = [...versions].reverse().map((v) => ({
|
|
2106
|
+
n: v.n,
|
|
2107
|
+
first: v.first,
|
|
2108
|
+
at: v.at,
|
|
2109
|
+
by: v.by,
|
|
2110
|
+
name: v.user.name,
|
|
2111
|
+
userId: v.user.id,
|
|
2112
|
+
...v.role ? { role: v.role } : {},
|
|
2113
|
+
...v.restoredFrom !== void 0 ? { restoredFrom: v.restoredFrom } : {},
|
|
2114
|
+
...v.restoredPart ? { restoredPart: true } : {},
|
|
2115
|
+
...v === newest ? { current: true } : {},
|
|
2116
|
+
preview: this.options.previewUrl(v.n),
|
|
2117
|
+
files: (changes.get(v.n) ?? []).map((c) => ({ path: c.path, op: c.op, label: label(c.path) })),
|
|
2118
|
+
...v.overwrote?.length ? { overwrote: v.overwrote.map((o) => ({ path: o.path, label: label(o.path), name: o.user.name, by: o.by, n: o.n, ...o.soft ? { soft: true } : {} })) } : {}
|
|
2119
|
+
}));
|
|
2120
|
+
return { me: viewer.user.id, canRestore: canEditAs(viewer.role), rows, notices: this.notices(viewer, label) };
|
|
2121
|
+
}
|
|
2122
|
+
/** Answers `__history…` (`sub` is what follows it); false for a path it doesn't know. */
|
|
2123
|
+
async handle(request, response, sub, viewer) {
|
|
2124
|
+
const method = request.method ?? "GET";
|
|
2125
|
+
const url = new URL(request.url ?? "/", "http://local");
|
|
2126
|
+
if (method === "POST" && !sameOrigin(request)) {
|
|
2127
|
+
request.resume();
|
|
2128
|
+
sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
|
|
2129
|
+
return true;
|
|
2130
|
+
}
|
|
2131
|
+
if (method === "GET" && (sub === "" || sub === "/")) return sendJson2(response, 200, this.list(viewer)), true;
|
|
2132
|
+
if (sub === "/notices") {
|
|
2133
|
+
if (method === "GET") return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
|
|
2134
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" }), true;
|
|
2135
|
+
const body = await this.body(request, response);
|
|
2136
|
+
if (!body) return true;
|
|
2137
|
+
if (typeof body.dismiss !== "string") return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {dismiss: id}." }), true;
|
|
2138
|
+
this.history.dismiss(viewer.user.id, body.dismiss);
|
|
2139
|
+
return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
|
|
2140
|
+
}
|
|
2141
|
+
const versionRoute = /^\/(\d+)\/(diff|restore)$/.exec(sub);
|
|
2142
|
+
if (versionRoute) {
|
|
2143
|
+
const n = Number(versionRoute[1]);
|
|
2144
|
+
const version = this.history.version(n);
|
|
2145
|
+
if (!version) return sendJson2(response, 404, { code: "not_found", message: `There is no version ${n} (it may be too old to keep).` }), true;
|
|
2146
|
+
if (versionRoute[2] === "diff") {
|
|
2147
|
+
if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
|
|
2148
|
+
const p = url.searchParams.get("path") ?? "";
|
|
2149
|
+
if (!(p in version.changes)) return sendJson2(response, 404, { code: "not_found", message: `Version ${n} did not change ${p}.` }), true;
|
|
2150
|
+
const before = this.history.stateBefore(n).get(p) ?? null;
|
|
2151
|
+
const after = version.changes[p] ?? null;
|
|
2152
|
+
return sendJson2(response, 200, this.diff(p, before, after)), true;
|
|
2153
|
+
}
|
|
2154
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
|
|
2155
|
+
if (!canEditAs(viewer.role)) return sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can bring a version back." }), true;
|
|
2156
|
+
const body = await this.body(request, response);
|
|
2157
|
+
if (!body) return true;
|
|
2158
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : void 0;
|
|
2159
|
+
const changes = this.history.restoreTo(n, paths, this.options.tracked);
|
|
2160
|
+
if (!changes) return sendJson2(response, 409, { code: "unavailable", message: `Version ${n} can't be brought back: a file it holds is missing from the history.` }), true;
|
|
2161
|
+
const made = await this.options.apply(changes, whoOf(viewer), { restoredFrom: n });
|
|
2162
|
+
this.options.log(`history: ${viewer.user.name} brought back version ${n}${paths ? ` (${paths.join(", ")})` : ""}${made ? ` as version ${made.n}` : " (nothing to change)"}`);
|
|
2163
|
+
return sendJson2(response, 200, { version: made?.n ?? null, changed: [...changes.keys()] }), true;
|
|
2164
|
+
}
|
|
2165
|
+
const noticeRoute = /^\/notice\/([0-9a-f]{6,32})\/(diff|recover)$/.exec(sub);
|
|
2166
|
+
if (noticeRoute) {
|
|
2167
|
+
const notice = this.history.notice(noticeRoute[1]);
|
|
2168
|
+
if (!notice || notice.to !== viewer.user.id) return sendJson2(response, 404, { code: "not_found", message: "No such notice." }), true;
|
|
2169
|
+
if (noticeRoute[2] === "diff") {
|
|
2170
|
+
if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
|
|
2171
|
+
const now2 = this.history.head().get(notice.path) ?? null;
|
|
2172
|
+
const nowBytes = now2 === null ? null : this.history.read(now2);
|
|
2173
|
+
const back2 = this.recovered(notice, nowBytes);
|
|
2174
|
+
if (now2 && !nowBytes || !back2) return sendJson2(response, 200, { path: notice.path, label: this.options.labels()(notice.path), missing: true, added: 0, removed: 0, lines: [], now: now2 }), true;
|
|
2175
|
+
return sendJson2(response, 200, { ...this.diffBytes(notice.path, nowBytes, back2.bytes), now: now2 }), true;
|
|
2176
|
+
}
|
|
2177
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
|
|
2178
|
+
if (!canEditAs(viewer.role)) return request.resume(), sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can change the files." }), true;
|
|
2179
|
+
const body = await this.body(request, response);
|
|
2180
|
+
if (!body) return true;
|
|
2181
|
+
const now = this.history.head().get(notice.path) ?? null;
|
|
2182
|
+
if ("now" in body && body.now !== now) {
|
|
2183
|
+
return sendJson2(response, 409, { code: "changed", message: "This changed while you were looking. Compare again." }), true;
|
|
2184
|
+
}
|
|
2185
|
+
const back = this.recovered(notice, now === null ? null : this.history.read(now));
|
|
2186
|
+
if (!back) return sendJson2(response, 409, { code: "unavailable", message: "That content is no longer in the history." }), true;
|
|
2187
|
+
const made = await this.options.apply(/* @__PURE__ */ new Map([[notice.path, back.bytes]]), whoOf(viewer), { restoredFrom: notice.lost.n, ...back.part ? { restoredPart: true } : {} });
|
|
2188
|
+
this.history.dismiss(viewer.user.id, notice.id);
|
|
2189
|
+
this.options.log(`history: ${viewer.user.name} brought back ${notice.lost.user.name}'s ${back.bytes === null ? "deletion of " : ""}${notice.path} (version ${notice.lost.n}${back.part ? ", what was replaced" : ""})`);
|
|
2190
|
+
return sendJson2(response, 200, { version: made?.n ?? null }), true;
|
|
2191
|
+
}
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
/** Before → after of one file, with the versions' names for the panel's header. */
|
|
2195
|
+
diff(p, before, after) {
|
|
2196
|
+
const read = (sha) => sha === null ? null : this.history.read(sha);
|
|
2197
|
+
const a = read(before);
|
|
2198
|
+
const b = read(after);
|
|
2199
|
+
if (before && !a || after && !b) return { path: p, label: this.options.labels()(p), missing: true, added: 0, removed: 0, lines: [] };
|
|
2200
|
+
return this.diffBytes(p, a, b);
|
|
2201
|
+
}
|
|
2202
|
+
diffBytes(p, a, b) {
|
|
2203
|
+
const [x, y] = this.options.diffSides ? this.options.diffSides(p, a, b) : [a, b];
|
|
2204
|
+
return { path: p, label: this.options.labels()(p), ...diffContents(x, y) };
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* What bringing a notice's lost content back writes to its file (`now`: what the file holds): the lost content merged
|
|
2208
|
+
* into it when the notice has a base and the server can merge (`part` when that is not the whole lost content), else
|
|
2209
|
+
* the whole lost content; a deletion deletes it again (bytes null). null: not in the history.
|
|
2210
|
+
*/
|
|
2211
|
+
recovered(notice, now) {
|
|
2212
|
+
if (notice.lost.sha === null) return { bytes: null, part: false };
|
|
2213
|
+
const lost = this.history.read(notice.lost.sha);
|
|
2214
|
+
if (!lost) return null;
|
|
2215
|
+
const base = notice.base ? this.history.read(notice.base) : null;
|
|
2216
|
+
const merged = base && this.options.recover ? this.options.recover(notice.path, base, lost, now) : null;
|
|
2217
|
+
return merged ? { bytes: merged, part: !merged.equals(lost) } : { bytes: lost, part: false };
|
|
2218
|
+
}
|
|
2219
|
+
async body(request, response) {
|
|
2220
|
+
try {
|
|
2221
|
+
const raw = (await readBody(request, MAX_BODY)).toString("utf8");
|
|
2222
|
+
const body = raw.trim() ? JSON.parse(raw) : {};
|
|
2223
|
+
if (typeof body === "object" && body !== null && !Array.isArray(body)) return body;
|
|
2224
|
+
} catch {
|
|
2225
|
+
}
|
|
2226
|
+
sendJson2(response, 400, { code: "invalid_argument", message: "Expected a JSON object." });
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2230
|
+
function noticeView(n, label, lostAt, gone) {
|
|
2231
|
+
return {
|
|
2232
|
+
...lostAt ? { lostAt } : {},
|
|
2233
|
+
id: n.id,
|
|
2234
|
+
kind: n.kind,
|
|
2235
|
+
at: n.at,
|
|
2236
|
+
path: n.path,
|
|
2237
|
+
label: label(n.path),
|
|
2238
|
+
n: n.n,
|
|
2239
|
+
name: n.by.name,
|
|
2240
|
+
by: n.byAuthor,
|
|
2241
|
+
lostName: n.lost.user.name,
|
|
2242
|
+
lostBy: n.lost.by,
|
|
2243
|
+
lostN: n.lost.n,
|
|
2244
|
+
...n.soft ? { soft: true } : {},
|
|
2245
|
+
// Bringing it back puts back only what was replaced (a write with a base); into a deleted file, all of it.
|
|
2246
|
+
...n.base && !gone ? { part: true } : {},
|
|
2247
|
+
...gone ? { removed: true } : {},
|
|
2248
|
+
// What was replaced is a deletion: bringing it back deletes the file again.
|
|
2249
|
+
...n.lost.sha === null ? { deleted: true } : {}
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
var plain = (text) => ({ en: text, zh: text });
|
|
2253
|
+
|
|
2254
|
+
var PEER_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
2255
|
+
function agentWhere(main2, count, label) {
|
|
2256
|
+
const where = main2 ? label(main2) : null;
|
|
2257
|
+
if (!where || count <= 1) return where;
|
|
2258
|
+
return { en: `${where.en} and ${count - 1} more`, zh: `${where.zh} \u7B49 ${count} \u5904` };
|
|
2259
|
+
}
|
|
2260
|
+
var AGENT_COLOR = "#d25f25";
|
|
2261
|
+
var MAX_PEERS = 64;
|
|
2262
|
+
var MAX_PATCH_BYTES = 16 << 10;
|
|
2263
|
+
var MAX_FIELDS = 12;
|
|
2264
|
+
var MAX_PRESENCE_BYTES = 6 << 10;
|
|
2265
|
+
var BROADCAST_MS = 50;
|
|
2266
|
+
var LEAVE_GRACE_MS = 4e3;
|
|
2267
|
+
var AGENT_LINGER_MS = 2e4;
|
|
2268
|
+
var AGENT_PEER = "agent-claude";
|
|
2269
|
+
var PresenceHub = class {
|
|
2270
|
+
peers = /* @__PURE__ */ new Map();
|
|
2271
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2272
|
+
agent = null;
|
|
2273
|
+
timer = null;
|
|
2274
|
+
/** Adds a page's stream; `send` writes one SSE frame to it. Returns what to call when the stream closes. */
|
|
2275
|
+
join(peer, viewer, send) {
|
|
2276
|
+
let entry = this.peers.get(peer);
|
|
2277
|
+
if (entry && entry.viewer.user.id !== viewer.user.id) {
|
|
2278
|
+
entry = void 0;
|
|
2279
|
+
peer = "";
|
|
2280
|
+
}
|
|
2281
|
+
if (peer && !entry && this.peers.size < MAX_PEERS) {
|
|
2282
|
+
entry = { viewer, presence: {}, updatedAt: Date.now(), streams: 0 };
|
|
2283
|
+
this.peers.set(peer, entry);
|
|
2284
|
+
}
|
|
2285
|
+
if (entry) {
|
|
2286
|
+
entry.streams += 1;
|
|
2287
|
+
if (entry.leaving) clearTimeout(entry.leaving);
|
|
2288
|
+
entry.leaving = void 0;
|
|
2289
|
+
}
|
|
2290
|
+
this.listeners.add(send);
|
|
2291
|
+
send(this.frame());
|
|
2292
|
+
this.schedule();
|
|
2293
|
+
let open = true;
|
|
2294
|
+
return () => {
|
|
2295
|
+
if (!open) return;
|
|
2296
|
+
open = false;
|
|
2297
|
+
this.listeners.delete(send);
|
|
2298
|
+
if (!entry) return;
|
|
2299
|
+
entry.streams -= 1;
|
|
2300
|
+
if (entry.streams > 0) return;
|
|
2301
|
+
const leaving = entry;
|
|
2302
|
+
leaving.leaving = setTimeout(() => {
|
|
2303
|
+
if (this.peers.get(peer) === leaving && leaving.streams === 0) {
|
|
2304
|
+
this.peers.delete(peer);
|
|
2305
|
+
this.schedule();
|
|
2306
|
+
}
|
|
2307
|
+
}, LEAVE_GRACE_MS);
|
|
2308
|
+
leaving.leaving.unref?.();
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
/** Merges a page's patch into its presence; false when the peer isn't this person's open page or the patch is bad. */
|
|
2312
|
+
update(peer, viewer, patch) {
|
|
2313
|
+
const entry = this.peers.get(peer);
|
|
2314
|
+
if (!entry || entry.viewer.user.id !== viewer.user.id) return false;
|
|
2315
|
+
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) return false;
|
|
2316
|
+
const next = { ...entry.presence };
|
|
2317
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
2318
|
+
if (value === null || value === void 0) delete next[key];
|
|
2319
|
+
else next[key] = value;
|
|
2320
|
+
}
|
|
2321
|
+
if (Object.keys(next).length > MAX_FIELDS || Buffer.byteLength(JSON.stringify(next)) > MAX_PRESENCE_BYTES) return false;
|
|
2322
|
+
entry.presence = next;
|
|
2323
|
+
entry.updatedAt = Date.now();
|
|
2324
|
+
this.schedule();
|
|
2325
|
+
return true;
|
|
2326
|
+
}
|
|
2327
|
+
/** The agent changed the artifact: it shows on the list (with `context`, where it worked) for a while. */
|
|
2328
|
+
agentActive(agent = {}) {
|
|
2329
|
+
if (this.agent) clearTimeout(this.agent.timer);
|
|
2330
|
+
const presence = { who: { n: agent.name ?? "Agent", c: AGENT_COLOR } };
|
|
2331
|
+
if (agent.context) presence.context = agent.context;
|
|
2332
|
+
if (agent.where) presence.where = agent.where;
|
|
2333
|
+
const timer = setTimeout(() => {
|
|
2334
|
+
this.agent = null;
|
|
2335
|
+
this.schedule();
|
|
2336
|
+
}, AGENT_LINGER_MS);
|
|
2337
|
+
timer.unref?.();
|
|
2338
|
+
this.agent = { presence, updatedAt: Date.now(), timer };
|
|
2339
|
+
this.schedule();
|
|
2340
|
+
}
|
|
2341
|
+
/** Everyone on the list now. */
|
|
2342
|
+
list() {
|
|
2343
|
+
const views = [];
|
|
2344
|
+
for (const [peer, entry] of this.peers) {
|
|
2345
|
+
const who = entry.presence.who;
|
|
2346
|
+
const presence = { ...entry.presence, who: { ...typeof who === "object" && who !== null && !Array.isArray(who) ? who : {}, n: entry.viewer.user.name } };
|
|
2347
|
+
views.push({ peer, by: entry.viewer.user.id, name: entry.viewer.user.name, role: entry.viewer.role, presence, updatedAt: entry.updatedAt });
|
|
2348
|
+
}
|
|
2349
|
+
if (this.agent) {
|
|
2350
|
+
const name = String(this.agent.presence.who.n ?? "Agent");
|
|
2351
|
+
views.push({ peer: AGENT_PEER, by: "agent", name, kind: "agent", presence: this.agent.presence, updatedAt: this.agent.updatedAt });
|
|
2352
|
+
}
|
|
2353
|
+
return views;
|
|
2354
|
+
}
|
|
2355
|
+
/** Answers POST `__presence` ({peer, patch}); GET gives the list. */
|
|
2356
|
+
async handle(request, response, viewer) {
|
|
2357
|
+
const method = request.method ?? "GET";
|
|
2358
|
+
if (method === "GET") return sendJson2(response, 200, { peers: this.list() });
|
|
2359
|
+
if (method !== "POST") {
|
|
2360
|
+
request.resume();
|
|
2361
|
+
return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" });
|
|
2362
|
+
}
|
|
2363
|
+
if (!sameOrigin(request)) {
|
|
2364
|
+
request.resume();
|
|
2365
|
+
return sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
|
|
2366
|
+
}
|
|
2367
|
+
let body;
|
|
2368
|
+
try {
|
|
2369
|
+
body = JSON.parse((await readBody(request, MAX_PATCH_BYTES)).toString("utf8"));
|
|
2370
|
+
} catch {
|
|
2371
|
+
return sendJson2(response, 400, { code: "invalid_argument", message: `Expected {peer, patch} under ${MAX_PATCH_BYTES} bytes.` });
|
|
2372
|
+
}
|
|
2373
|
+
const { peer, patch } = typeof body === "object" && body !== null ? body : {};
|
|
2374
|
+
if (typeof peer !== "string" || !PEER_ID.test(peer)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {peer, patch}." });
|
|
2375
|
+
if (!this.update(peer, viewer, patch)) return sendJson2(response, 409, { code: "not_joined", message: "That page is not open here (its event stream is closed)." });
|
|
2376
|
+
response.writeHead(204, { "Cache-Control": "no-store" }).end();
|
|
2377
|
+
}
|
|
2378
|
+
/** Ends the timers (the server is closing). */
|
|
2379
|
+
close() {
|
|
2380
|
+
if (this.timer) clearTimeout(this.timer);
|
|
2381
|
+
if (this.agent) clearTimeout(this.agent.timer);
|
|
2382
|
+
for (const entry of this.peers.values()) if (entry.leaving) clearTimeout(entry.leaving);
|
|
2383
|
+
this.peers.clear();
|
|
2384
|
+
this.listeners.clear();
|
|
2385
|
+
}
|
|
2386
|
+
frame() {
|
|
2387
|
+
return `event: peers
|
|
2388
|
+
data: ${JSON.stringify({ peers: this.list() })}
|
|
2389
|
+
|
|
2390
|
+
`;
|
|
2391
|
+
}
|
|
2392
|
+
schedule() {
|
|
2393
|
+
if (this.timer) return;
|
|
2394
|
+
this.timer = setTimeout(() => {
|
|
2395
|
+
this.timer = null;
|
|
2396
|
+
const frame = this.frame();
|
|
2397
|
+
for (const send of this.listeners) send(frame);
|
|
2398
|
+
}, BROADCAST_MS);
|
|
2399
|
+
this.timer.unref?.();
|
|
2400
|
+
}
|
|
2401
|
+
};
|
|
2402
|
+
|
|
1474
2403
|
var KIND = "";
|
|
1475
2404
|
var NOUN = "";
|
|
1476
2405
|
var INDEX_FILE = "";
|
|
@@ -1478,6 +2407,8 @@ var LAYOUT;
|
|
|
1478
2407
|
var pageScriptSrc;
|
|
1479
2408
|
var sessionExtras;
|
|
1480
2409
|
var commentLocation;
|
|
2410
|
+
var kindLabels;
|
|
2411
|
+
var kindAgentContext;
|
|
1481
2412
|
var SCRIPT = "";
|
|
1482
2413
|
var EDITOR_DIR = "";
|
|
1483
2414
|
var MIME = {
|
|
@@ -1551,6 +2482,9 @@ var ROUTES = {
|
|
|
1551
2482
|
session: "__deck/session"
|
|
1552
2483
|
};
|
|
1553
2484
|
var COMMENTS_ROUTE = "__comments";
|
|
2485
|
+
var HISTORY_ROUTE = "__history";
|
|
2486
|
+
var PRESENCE_ROUTE = "__presence";
|
|
2487
|
+
var PREVIEW_PATH = /^v\/(\d{1,9})(\/.*)?$/;
|
|
1554
2488
|
var ASSET_INDEX = "index.json";
|
|
1555
2489
|
var BLOB_ID = /^[0-9a-f]{32}$/;
|
|
1556
2490
|
var BLOB_PATH = /^_blob\/([0-9a-f]{32})$/;
|
|
@@ -1561,8 +2495,11 @@ var SSE_KEEPALIVE_MS2 = 25e3;
|
|
|
1561
2495
|
var WATCH_DEBOUNCE_MS2 = 300;
|
|
1562
2496
|
var POLL_MS2 = 2e3;
|
|
1563
2497
|
var CHANGE_LOG_SIZE = 64;
|
|
2498
|
+
var OVERWRITE_WINDOW_MS = 6e4;
|
|
2499
|
+
var filesOf = (listing, paths) => Object.fromEntries(paths.map((p) => [p, listing.files[p]?.sha256 ?? null]));
|
|
2500
|
+
var authorOf = (who) => ({ id: who.user.id, name: who.user.name, ...who.by === "agent" ? { agent: true } : {} });
|
|
1564
2501
|
var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
1565
|
-
var
|
|
2502
|
+
var sha2562 = (bytes) => createHash3("sha256").update(bytes).digest("hex");
|
|
1566
2503
|
function contentTypeOf(file) {
|
|
1567
2504
|
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
1568
2505
|
return MIME[ext] ?? "application/octet-stream";
|
|
@@ -1572,6 +2509,7 @@ function isProjectPath(p) {
|
|
|
1572
2509
|
if (typeof p !== "string" || !p.startsWith("project/") || p.includes("\\") || p.includes("\0")) return false;
|
|
1573
2510
|
return p.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
1574
2511
|
}
|
|
2512
|
+
var SHA256 = /^[0-9a-f]{64}$/;
|
|
1575
2513
|
var isWriteEntry = (entry) => entry !== null && !("delete" in entry);
|
|
1576
2514
|
function isWireEntry(value) {
|
|
1577
2515
|
if (value === null) return true;
|
|
@@ -1585,7 +2523,7 @@ function inlineScriptHashes(html) {
|
|
|
1585
2523
|
const hashes = [];
|
|
1586
2524
|
for (const match of html.matchAll(/<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi)) {
|
|
1587
2525
|
if (/\ssrc\s*=/i.test(match[1] ?? "")) continue;
|
|
1588
|
-
hashes.push("sha256-" +
|
|
2526
|
+
hashes.push("sha256-" + createHash3("sha256").update(match[2] ?? "", "utf8").digest("base64"));
|
|
1589
2527
|
}
|
|
1590
2528
|
return hashes;
|
|
1591
2529
|
}
|
|
@@ -1607,7 +2545,7 @@ function pageCsp(scriptSrc) {
|
|
|
1607
2545
|
].join("; ");
|
|
1608
2546
|
}
|
|
1609
2547
|
function versionOf(files) {
|
|
1610
|
-
const digest =
|
|
2548
|
+
const digest = sha2562(
|
|
1611
2549
|
Object.entries(files).map(([p, f]) => `${p}:${f.sha256}`).join("\n")
|
|
1612
2550
|
);
|
|
1613
2551
|
return "local-" + digest.slice(0, 16);
|
|
@@ -1618,7 +2556,7 @@ async function collectFiles(root, limit) {
|
|
|
1618
2556
|
const seen = /* @__PURE__ */ new Set();
|
|
1619
2557
|
let truncated = false;
|
|
1620
2558
|
const visit = async (relDir, descend) => {
|
|
1621
|
-
const dir = relDir === "" ? root :
|
|
2559
|
+
const dir = relDir === "" ? root : path6.join(root, ...relDir.split("/"));
|
|
1622
2560
|
let entries;
|
|
1623
2561
|
try {
|
|
1624
2562
|
entries = await fsp2.readdir(dir, { withFileTypes: true });
|
|
@@ -1649,8 +2587,8 @@ async function collectFiles(root, limit) {
|
|
|
1649
2587
|
return { rels, truncated };
|
|
1650
2588
|
}
|
|
1651
2589
|
async function writeFileAtomic(file, bytes) {
|
|
1652
|
-
await fsp2.mkdir(
|
|
1653
|
-
const tmp =
|
|
2590
|
+
await fsp2.mkdir(path6.dirname(file), { recursive: true });
|
|
2591
|
+
const tmp = path6.join(path6.dirname(file), `.${path6.basename(file)}.${randomUUID()}.tmp`);
|
|
1654
2592
|
try {
|
|
1655
2593
|
await fsp2.writeFile(tmp, bytes);
|
|
1656
2594
|
await fsp2.rename(tmp, file);
|
|
@@ -1662,19 +2600,27 @@ async function writeFileAtomic(file, bytes) {
|
|
|
1662
2600
|
}
|
|
1663
2601
|
var Store = class {
|
|
1664
2602
|
root;
|
|
2603
|
+
history;
|
|
1665
2604
|
log;
|
|
1666
2605
|
/** project/<path> → sha256 of the page's last save not yet seen by the watcher ('' for a delete). */
|
|
1667
2606
|
ownWrites;
|
|
2607
|
+
/** "<user id>\0project/<path>" → when the server last refused that person's save of the file as a conflict. */
|
|
2608
|
+
refused;
|
|
1668
2609
|
queue;
|
|
1669
2610
|
seq;
|
|
1670
2611
|
truncationReported;
|
|
1671
|
-
|
|
2612
|
+
/** The last listing left files out (more than the format's limit): its missing files are not deletes. */
|
|
2613
|
+
truncated;
|
|
2614
|
+
constructor(root, history, log) {
|
|
1672
2615
|
this.root = root;
|
|
2616
|
+
this.history = history;
|
|
1673
2617
|
this.log = log;
|
|
1674
2618
|
this.ownWrites = /* @__PURE__ */ new Map();
|
|
2619
|
+
this.refused = /* @__PURE__ */ new Map();
|
|
1675
2620
|
this.queue = Promise.resolve();
|
|
1676
2621
|
this.seq = 0;
|
|
1677
2622
|
this.truncationReported = false;
|
|
2623
|
+
this.truncated = false;
|
|
1678
2624
|
}
|
|
1679
2625
|
/** Run `work` after every save, upload and change check before it, and before the next. */
|
|
1680
2626
|
exclusive(work) {
|
|
@@ -1692,11 +2638,11 @@ var Store = class {
|
|
|
1692
2638
|
async diskPath(rel) {
|
|
1693
2639
|
const realRoot = await fsp2.realpath(this.root).catch(() => null);
|
|
1694
2640
|
if (realRoot === null) return null;
|
|
1695
|
-
const file =
|
|
1696
|
-
for (let dir =
|
|
2641
|
+
const file = path6.join(realRoot, ...rel.split("/"));
|
|
2642
|
+
for (let dir = path6.dirname(file); ; dir = path6.dirname(dir)) {
|
|
1697
2643
|
const real = await fsp2.realpath(dir).catch(() => null);
|
|
1698
2644
|
if (real === null) {
|
|
1699
|
-
if (
|
|
2645
|
+
if (path6.dirname(dir) === dir) return null;
|
|
1700
2646
|
continue;
|
|
1701
2647
|
}
|
|
1702
2648
|
if (real !== dir) return null;
|
|
@@ -1708,6 +2654,7 @@ var Store = class {
|
|
|
1708
2654
|
async listing() {
|
|
1709
2655
|
const limit = LAYOUT.maxFiles;
|
|
1710
2656
|
const { rels, truncated } = await collectFiles(this.root, limit);
|
|
2657
|
+
this.truncated = truncated;
|
|
1711
2658
|
if (truncated && !this.truncationReported) {
|
|
1712
2659
|
this.truncationReported = true;
|
|
1713
2660
|
this.log(`The ${NOUN} folder has more than ${limit} files; the editor lists only the first ${limit}: ${this.root}`);
|
|
@@ -1718,7 +2665,7 @@ var Store = class {
|
|
|
1718
2665
|
if (file === null) continue;
|
|
1719
2666
|
const bytes = await fsp2.readFile(file).catch(() => null);
|
|
1720
2667
|
if (bytes === null) continue;
|
|
1721
|
-
files[`project/${rel}`] = { sha256:
|
|
2668
|
+
files[`project/${rel}`] = { sha256: sha2562(bytes), size: bytes.length, contentType: contentTypeOf(rel) };
|
|
1722
2669
|
}
|
|
1723
2670
|
return { ver: versionOf(files), files };
|
|
1724
2671
|
}
|
|
@@ -1728,11 +2675,14 @@ var Store = class {
|
|
|
1728
2675
|
if (file === null) return null;
|
|
1729
2676
|
return fsp2.readFile(file).catch(() => null);
|
|
1730
2677
|
}
|
|
1731
|
-
/**
|
|
1732
|
-
|
|
1733
|
-
|
|
2678
|
+
/**
|
|
2679
|
+
* A save from the page by `who`: all entries or none (ifMatch is checked for all before anything is written). It is
|
|
2680
|
+
* a version in the history under their name; what changed on disk before it is one of its own, Claude's.
|
|
2681
|
+
*/
|
|
2682
|
+
publish(batch, who, over = [], merged = []) {
|
|
2683
|
+
return this.exclusive(() => this.publishNow(batch, who, over, merged));
|
|
1734
2684
|
}
|
|
1735
|
-
async publishNow(batch) {
|
|
2685
|
+
async publishNow(batch, who, over, merged) {
|
|
1736
2686
|
const files = /* @__PURE__ */ new Map();
|
|
1737
2687
|
let total = 0;
|
|
1738
2688
|
for (const [p, entry] of Object.entries(batch)) {
|
|
@@ -1751,6 +2701,7 @@ var Store = class {
|
|
|
1751
2701
|
return { status: 413, body: { code: "too_large", message: `a publish can write at most ${LAYOUT.maxPublishBytes} bytes` } };
|
|
1752
2702
|
}
|
|
1753
2703
|
const before = await this.listing();
|
|
2704
|
+
await this.syncDisk(before);
|
|
1754
2705
|
const conflicts = [];
|
|
1755
2706
|
for (const [p, entry] of Object.entries(batch)) {
|
|
1756
2707
|
const expected = entry?.ifMatch;
|
|
@@ -1758,24 +2709,121 @@ var Store = class {
|
|
|
1758
2709
|
const actual = before.files[p]?.sha256 ?? null;
|
|
1759
2710
|
if (actual !== expected) conflicts.push({ path: p, actual, expected });
|
|
1760
2711
|
}
|
|
1761
|
-
if (conflicts.length > 0)
|
|
2712
|
+
if (conflicts.length > 0) {
|
|
2713
|
+
const now = Date.now();
|
|
2714
|
+
for (const c of conflicts) this.refused.set(`${who.user.id}\0${c.path}`, now);
|
|
2715
|
+
return { status: 409, body: { code: "conflict", live: before.ver, paths: conflicts } };
|
|
2716
|
+
}
|
|
2717
|
+
for (const m of merged) {
|
|
2718
|
+
if (batch[m.path] !== void 0 && before.files[m.path]?.sha256 === m.sha) this.refused.delete(`${who.user.id}\0${m.path}`);
|
|
2719
|
+
}
|
|
2720
|
+
const chosen = /* @__PURE__ */ new Map();
|
|
2721
|
+
for (const o of over) {
|
|
2722
|
+
if (batch[o.path] === void 0 || before.files[o.path]?.sha256 !== o.sha) continue;
|
|
2723
|
+
this.refused.set(`${who.user.id}\0${o.path}`, Date.now());
|
|
2724
|
+
chosen.set(o.path, o.base);
|
|
2725
|
+
}
|
|
2726
|
+
const overwrote = this.overwrites(batch, before, who, chosen);
|
|
1762
2727
|
const shas = {};
|
|
2728
|
+
const written = /* @__PURE__ */ new Map();
|
|
1763
2729
|
for (const [p, entry] of Object.entries(batch)) {
|
|
1764
2730
|
const file = files.get(p);
|
|
1765
2731
|
if (!isWriteEntry(entry)) {
|
|
1766
2732
|
this.ownWrites.set(p, "");
|
|
1767
2733
|
await fsp2.rm(file, { force: true });
|
|
2734
|
+
written.set(p, null);
|
|
1768
2735
|
continue;
|
|
1769
2736
|
}
|
|
1770
2737
|
const bytes = Buffer.from(entry.base64, "base64");
|
|
1771
|
-
const sha =
|
|
2738
|
+
const sha = sha2562(bytes);
|
|
1772
2739
|
this.ownWrites.set(p, sha);
|
|
1773
2740
|
await writeFileAtomic(file, bytes);
|
|
1774
2741
|
shas[p] = sha;
|
|
2742
|
+
written.set(p, bytes);
|
|
1775
2743
|
}
|
|
2744
|
+
const version = this.history.record(written, who, overwrote.length ? { overwrote } : {});
|
|
1776
2745
|
const after = await this.listing();
|
|
1777
2746
|
this.seq += 1;
|
|
1778
|
-
return { status: 200, body: { version: after.ver, seq: this.seq, shas } };
|
|
2747
|
+
return { status: 200, body: { version: after.ver, seq: this.seq, shas }, version };
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* The files this save replaces without its page having seen them: the server refused this person's save of the file
|
|
2751
|
+
* as a conflict moments ago (someone else had saved it since their page loaded it), and now the page saves on top of
|
|
2752
|
+
* what that someone saved. (The editors take the newer file and save their own over it.) Their own other tab's save,
|
|
2753
|
+
* and the content the history began with, are no one else's. `chosen`: the files the page says its person saw and
|
|
2754
|
+
* chose to save over (marked as refused above), whose overwrite is `chosen`, with the version the edits started
|
|
2755
|
+
* from as its `base` when the history holds that content (and it is not what was lost, which would bring back nothing).
|
|
2756
|
+
* A page that names no base (Keep mine from a bar or a banner, a save on top of a refused one) gets the file as it was
|
|
2757
|
+
* right before the lost version: what both started from when one saved while the other was editing, so Bring back
|
|
2758
|
+
* returns what that version changed and leaves the saver's other changes.
|
|
2759
|
+
*/
|
|
2760
|
+
overwrites(batch, before, who, chosen) {
|
|
2761
|
+
const now = Date.now();
|
|
2762
|
+
const out = [];
|
|
2763
|
+
for (const [p, entry] of Object.entries(batch)) {
|
|
2764
|
+
const key = `${who.user.id}\0${p}`;
|
|
2765
|
+
const at = this.refused.get(key);
|
|
2766
|
+
if (at === void 0) continue;
|
|
2767
|
+
this.refused.delete(key);
|
|
2768
|
+
const lost = before.files[p]?.sha256;
|
|
2769
|
+
if (now - at > OVERWRITE_WINDOW_MS || !lost) continue;
|
|
2770
|
+
if ((isWriteEntry(entry) ? sha2562(Buffer.from(entry.base64, "base64")) : null) === lost) continue;
|
|
2771
|
+
const last = this.history.lastChange(p);
|
|
2772
|
+
if (!last || last.sha !== lost || last.version.by === "system" || last.version.user.id === who.user.id) continue;
|
|
2773
|
+
const base = chosen.get(p) ?? this.history.stateBefore(last.version.n).get(p);
|
|
2774
|
+
out.push({
|
|
2775
|
+
path: p,
|
|
2776
|
+
lost,
|
|
2777
|
+
n: last.version.n,
|
|
2778
|
+
by: last.version.by,
|
|
2779
|
+
user: { ...last.version.user },
|
|
2780
|
+
...chosen.has(p) ? { chosen: true } : {},
|
|
2781
|
+
...base && base !== lost && this.history.size(base) !== null ? { base } : {}
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
for (const [key, at] of this.refused) if (now - at > OVERWRITE_WINDOW_MS) this.refused.delete(key);
|
|
2785
|
+
return out;
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* Records what changed on disk since the history last looked, as Claude's (the agent's, or anything else on this
|
|
2789
|
+
* computer; the files as they were when the history began are a version of no one's). Runs inside `exclusive`.
|
|
2790
|
+
*/
|
|
2791
|
+
async syncDisk(listing) {
|
|
2792
|
+
const head = this.history.head();
|
|
2793
|
+
const changes = /* @__PURE__ */ new Map();
|
|
2794
|
+
for (const [p, f] of Object.entries(listing.files)) {
|
|
2795
|
+
if (head.get(p) === f.sha256) continue;
|
|
2796
|
+
const bytes = await this.readProjectFile(p);
|
|
2797
|
+
if (bytes !== null) changes.set(p, bytes);
|
|
2798
|
+
}
|
|
2799
|
+
if (!this.truncated) {
|
|
2800
|
+
for (const p of head.keys()) if (!(p in listing.files) && this.fileOf(p) !== null) changes.set(p, null);
|
|
2801
|
+
}
|
|
2802
|
+
if (changes.size === 0) return null;
|
|
2803
|
+
const version = this.history.record(changes, this.history.empty ? SYSTEM : AGENT);
|
|
2804
|
+
if (version) this.log(`history: version ${version.n}${version.by === "agent" ? " (changed on disk)" : " (the files as they were)"}: ${[...changes.keys()].map((p) => p.replace(/^project\//, "")).join(", ")}`);
|
|
2805
|
+
return version;
|
|
2806
|
+
}
|
|
2807
|
+
/** Writes contents from the history (a restore) and records them as `who`'s version. */
|
|
2808
|
+
applyFromHistory(changes, who, extra) {
|
|
2809
|
+
return this.exclusive(async () => {
|
|
2810
|
+
await this.syncDisk(await this.listing());
|
|
2811
|
+
const written = /* @__PURE__ */ new Map();
|
|
2812
|
+
for (const [p, bytes] of changes) {
|
|
2813
|
+
const rel = this.fileOf(p);
|
|
2814
|
+
const file = rel === null ? null : await this.diskPath(rel);
|
|
2815
|
+
if (file === null) continue;
|
|
2816
|
+
if (bytes === null) {
|
|
2817
|
+
this.ownWrites.set(p, "");
|
|
2818
|
+
await fsp2.rm(file, { force: true });
|
|
2819
|
+
} else {
|
|
2820
|
+
this.ownWrites.set(p, sha2562(bytes));
|
|
2821
|
+
await writeFileAtomic(file, bytes);
|
|
2822
|
+
}
|
|
2823
|
+
written.set(p, bytes);
|
|
2824
|
+
}
|
|
2825
|
+
return { version: this.history.record(written, who, extra), paths: [...written.keys()] };
|
|
2826
|
+
});
|
|
1779
2827
|
}
|
|
1780
2828
|
async assetIndex() {
|
|
1781
2829
|
try {
|
|
@@ -1792,7 +2840,7 @@ var Store = class {
|
|
|
1792
2840
|
return this.exclusive(() => this.putBlobNow(bytes, contentType));
|
|
1793
2841
|
}
|
|
1794
2842
|
async putBlobNow(bytes, contentType) {
|
|
1795
|
-
const id =
|
|
2843
|
+
const id = sha2562(bytes).slice(0, 32);
|
|
1796
2844
|
const type = (contentType.split(";")[0] ?? "").trim().toLowerCase() || "application/octet-stream";
|
|
1797
2845
|
if (await this.findBlobFile(id) === null) {
|
|
1798
2846
|
const file = await this.diskPath(`${LAYOUT.blobDir}/${id}.${assetExtensionOf(type)}`);
|
|
@@ -1809,7 +2857,7 @@ var Store = class {
|
|
|
1809
2857
|
}
|
|
1810
2858
|
async findBlobFile(id) {
|
|
1811
2859
|
try {
|
|
1812
|
-
const names = await fsp2.readdir(
|
|
2860
|
+
const names = await fsp2.readdir(path6.join(this.root, LAYOUT.blobDir));
|
|
1813
2861
|
const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
|
|
1814
2862
|
if (name === void 0) return null;
|
|
1815
2863
|
const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
|
|
@@ -1836,7 +2884,7 @@ var Store = class {
|
|
|
1836
2884
|
let sha = "";
|
|
1837
2885
|
try {
|
|
1838
2886
|
const file = await this.diskPath(rel);
|
|
1839
|
-
if (file !== null && (await fsp2.lstat(file)).isFile()) sha =
|
|
2887
|
+
if (file !== null && (await fsp2.lstat(file)).isFile()) sha = sha2562(await fsp2.readFile(file));
|
|
1840
2888
|
} catch {
|
|
1841
2889
|
sha = "";
|
|
1842
2890
|
}
|
|
@@ -1864,13 +2912,13 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1864
2912
|
const directories = /* @__PURE__ */ new Map();
|
|
1865
2913
|
const visit = (rel, descend) => {
|
|
1866
2914
|
if (directories.size >= maxDirectories) return;
|
|
1867
|
-
const dir = rel === "" ? store.root :
|
|
1868
|
-
if (directories.has(dir) || !
|
|
1869
|
-
const info =
|
|
2915
|
+
const dir = rel === "" ? store.root : path6.join(store.root, ...rel.split("/"));
|
|
2916
|
+
if (directories.has(dir) || !fs7.existsSync(dir)) return;
|
|
2917
|
+
const info = fs7.lstatSync(dir);
|
|
1870
2918
|
if (!info.isDirectory()) return;
|
|
1871
2919
|
directories.set(dir, `${info.dev}:${info.ino}`);
|
|
1872
2920
|
if (!descend) return;
|
|
1873
|
-
for (const entry of
|
|
2921
|
+
for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
|
|
1874
2922
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
1875
2923
|
const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
1876
2924
|
if (LAYOUT.descendInto(child)) visit(child, true);
|
|
@@ -1887,19 +2935,19 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1887
2935
|
if (watchers.has(dir)) continue;
|
|
1888
2936
|
let watcher;
|
|
1889
2937
|
try {
|
|
1890
|
-
watcher =
|
|
2938
|
+
watcher = fs7.watch(dir, (event, name) => {
|
|
1891
2939
|
if (stopped) return;
|
|
1892
2940
|
if (name) {
|
|
1893
|
-
const changed =
|
|
2941
|
+
const changed = path6.join(dir, String(name));
|
|
1894
2942
|
if (event === "rename" && watchers.has(changed)) {
|
|
1895
2943
|
for (const [watchedDir, item] of watchers) {
|
|
1896
|
-
if (watchedDir === changed || watchedDir.startsWith(changed +
|
|
2944
|
+
if (watchedDir === changed || watchedDir.startsWith(changed + path6.sep)) {
|
|
1897
2945
|
item.watcher.close();
|
|
1898
2946
|
watchers.delete(watchedDir);
|
|
1899
2947
|
}
|
|
1900
2948
|
}
|
|
1901
2949
|
}
|
|
1902
|
-
const rel =
|
|
2950
|
+
const rel = path6.relative(store.root, changed).split(path6.sep).join("/");
|
|
1903
2951
|
if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
|
|
1904
2952
|
}
|
|
1905
2953
|
schedule();
|
|
@@ -1917,6 +2965,7 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1917
2965
|
try {
|
|
1918
2966
|
refresh();
|
|
1919
2967
|
const current = await store.listing();
|
|
2968
|
+
await store.syncDisk(current);
|
|
1920
2969
|
if (seen === null) {
|
|
1921
2970
|
seen = new Map(Object.entries(current.files).map(([p, f]) => [p, f.sha256]));
|
|
1922
2971
|
pollSignature = await signature();
|
|
@@ -1924,7 +2973,8 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1924
2973
|
}
|
|
1925
2974
|
const external = await store.externalChanges(/* @__PURE__ */ new Set([...seen.keys(), ...Object.keys(current.files)]), seen);
|
|
1926
2975
|
if (stopped || external.length === 0) return;
|
|
1927
|
-
|
|
2976
|
+
const after = await store.listing();
|
|
2977
|
+
onChange({ type: "external-change", paths: external, ver: after.ver, files: filesOf(after, external) });
|
|
1928
2978
|
} catch (error) {
|
|
1929
2979
|
onError(error);
|
|
1930
2980
|
}
|
|
@@ -1933,7 +2983,7 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1933
2983
|
const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
|
|
1934
2984
|
const parts = [];
|
|
1935
2985
|
for (const rel of rels) {
|
|
1936
|
-
const info = await fsp2.lstat(
|
|
2986
|
+
const info = await fsp2.lstat(path6.join(store.root, ...rel.split("/"))).catch(() => null);
|
|
1937
2987
|
parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
|
|
1938
2988
|
}
|
|
1939
2989
|
return parts.join("\n");
|
|
@@ -1964,10 +3014,11 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1964
3014
|
var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
|
|
1965
3015
|
async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
|
|
1966
3016
|
`), agentHook, lan = false, portGiven = false }) {
|
|
1967
|
-
const project =
|
|
3017
|
+
const project = path6.join(root, "project");
|
|
1968
3018
|
const stamp = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
|
|
1969
3019
|
const projectDir = await fsp2.realpath(project);
|
|
1970
|
-
const
|
|
3020
|
+
const history = new History(root);
|
|
3021
|
+
const store = new Store(projectDir, history, stamp);
|
|
1971
3022
|
const owner = localOwner();
|
|
1972
3023
|
const share = new ShareHost({
|
|
1973
3024
|
root,
|
|
@@ -1977,7 +3028,12 @@ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1
|
|
|
1977
3028
|
title: () => artifactTitle(root, projectDir),
|
|
1978
3029
|
log: stamp,
|
|
1979
3030
|
createServer: () => http.createServer(handle),
|
|
1980
|
-
uiFile:
|
|
3031
|
+
uiFile: path6.join(EDITOR_DIR, "share-ui.js"),
|
|
3032
|
+
// The History panel's address and what this person may do there; on a version's page (?at=n), which version.
|
|
3033
|
+
globals: (viewer, query) => {
|
|
3034
|
+
const at = Number(query.get("at") ?? "");
|
|
3035
|
+
return { __baoyuHistory: historyHost.bootGlobal(viewer, query.has("at") && Number.isInteger(at) ? at : void 0) };
|
|
3036
|
+
}
|
|
1981
3037
|
});
|
|
1982
3038
|
const comments = createCommentsService({
|
|
1983
3039
|
root,
|
|
@@ -1991,10 +3047,11 @@ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1
|
|
|
1991
3047
|
return viewer ? commentsViewer(viewer) : null;
|
|
1992
3048
|
}
|
|
1993
3049
|
});
|
|
1994
|
-
const grant =
|
|
3050
|
+
const grant = randomBytes2(24).toString("base64url");
|
|
1995
3051
|
const editorPath = `/${KIND}/${grant}/`;
|
|
1996
|
-
const boot =
|
|
3052
|
+
const boot = randomBytes2(6).toString("hex");
|
|
1997
3053
|
const clients = /* @__PURE__ */ new Map();
|
|
3054
|
+
const presence = new PresenceHub();
|
|
1998
3055
|
const changes = [];
|
|
1999
3056
|
let changeSeq = 0;
|
|
2000
3057
|
const pageCache = /* @__PURE__ */ new Map();
|
|
@@ -2006,15 +3063,58 @@ data: ${JSON.stringify(event)}
|
|
|
2006
3063
|
`;
|
|
2007
3064
|
const changed = (event, from) => {
|
|
2008
3065
|
changeSeq += 1;
|
|
2009
|
-
changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {} });
|
|
3066
|
+
changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {}, ...event.by ? { by: event.by } : {} });
|
|
2010
3067
|
if (changes.length > CHANGE_LOG_SIZE) changes.shift();
|
|
2011
3068
|
for (const [client, id] of clients) if (!from || id !== from) client.write(frame(event));
|
|
2012
3069
|
};
|
|
3070
|
+
const labels2 = () => {
|
|
3071
|
+
let named;
|
|
3072
|
+
try {
|
|
3073
|
+
named = kindLabels?.(projectDir);
|
|
3074
|
+
} catch {
|
|
3075
|
+
named = void 0;
|
|
3076
|
+
}
|
|
3077
|
+
return (p) => {
|
|
3078
|
+
const rel = p.replace(/^project\//, "");
|
|
3079
|
+
return named?.(rel) ?? plain(rel);
|
|
3080
|
+
};
|
|
3081
|
+
};
|
|
3082
|
+
const agentWorked = (paths) => {
|
|
3083
|
+
const rels = paths.map((p) => p.replace(/^project\//, ""));
|
|
3084
|
+
const main2 = [...paths].reverse().find((p) => !p.endsWith(`/${INDEX_FILE}`) && p !== INDEX_FILE) ?? paths[0];
|
|
3085
|
+
let context = null;
|
|
3086
|
+
try {
|
|
3087
|
+
context = kindAgentContext?.(projectDir, rels) ?? null;
|
|
3088
|
+
} catch {
|
|
3089
|
+
context = null;
|
|
3090
|
+
}
|
|
3091
|
+
presence.agentActive({ context, where: agentWhere(main2, paths.length, labels2()) });
|
|
3092
|
+
};
|
|
3093
|
+
const historyHost = new HistoryHost({
|
|
3094
|
+
history,
|
|
3095
|
+
labels: labels2,
|
|
3096
|
+
base: editorPath,
|
|
3097
|
+
previewUrl: (n) => `${editorPath}v/${n}/`,
|
|
3098
|
+
apply: async (files, who, extra) => {
|
|
3099
|
+
const { version, paths } = await store.applyFromHistory(files, who, extra);
|
|
3100
|
+
if (paths.length) {
|
|
3101
|
+
const listing = await store.listing();
|
|
3102
|
+
changed({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), by: authorOf(who) });
|
|
3103
|
+
}
|
|
3104
|
+
return version;
|
|
3105
|
+
},
|
|
3106
|
+
tracked: (p) => store.fileOf(p) !== null,
|
|
3107
|
+
// A notice whose overwrite named the version its saver's edits started from: what was lost goes back by line into
|
|
3108
|
+
// the file as it is now, the lost lines winning where both changed them; a deleted file gets all of it back.
|
|
3109
|
+
recover: (_path, base, lost, now) => now === null ? lost : mergeTextBytes(base, lost, now),
|
|
3110
|
+
log: stamp
|
|
3111
|
+
});
|
|
2013
3112
|
const stopWatch = watchArtifact(
|
|
2014
3113
|
store,
|
|
2015
3114
|
(event) => {
|
|
2016
3115
|
stamp(`changed on disk: ${event.paths.join(", ")}${clients.size ? "" : " (no page open)"}`);
|
|
2017
|
-
changed(event);
|
|
3116
|
+
changed({ ...event, by: authorOf(AGENT) });
|
|
3117
|
+
agentWorked(event.paths);
|
|
2018
3118
|
},
|
|
2019
3119
|
(error) => stamp(`watching failed: ${errorText(error)}`)
|
|
2020
3120
|
);
|
|
@@ -2034,7 +3134,9 @@ data: ${JSON.stringify(event)}
|
|
|
2034
3134
|
const missed = missedSince === null ? [] : truncated ? changes : changes.filter((c) => c.seq > missedSince && (!client || c.from !== client));
|
|
2035
3135
|
if (missed.length) {
|
|
2036
3136
|
const paths = [...new Set(missed.flatMap((c) => c.paths))];
|
|
2037
|
-
|
|
3137
|
+
const listing = await store.listing();
|
|
3138
|
+
const by = missed.every((c) => c.by?.id === missed[0].by?.id) ? missed[0].by : void 0;
|
|
3139
|
+
response.write(frame({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), ...by ? { by } : {} }));
|
|
2038
3140
|
} else {
|
|
2039
3141
|
response.write(`id: ${eventId()}
|
|
2040
3142
|
|
|
@@ -2042,11 +3144,13 @@ data: ${JSON.stringify(event)}
|
|
|
2042
3144
|
}
|
|
2043
3145
|
clients.set(response, client);
|
|
2044
3146
|
share.hold(viewer, response);
|
|
3147
|
+
const leave = presence.join(client, viewer, (text) => response.write(text));
|
|
2045
3148
|
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
|
|
2046
3149
|
keepAlive.unref?.();
|
|
2047
3150
|
const done = () => {
|
|
2048
3151
|
clearInterval(keepAlive);
|
|
2049
3152
|
clients.delete(response);
|
|
3153
|
+
leave();
|
|
2050
3154
|
};
|
|
2051
3155
|
request.on("close", done);
|
|
2052
3156
|
response.on("close", done);
|
|
@@ -2071,11 +3175,11 @@ data: ${JSON.stringify(event)}
|
|
|
2071
3175
|
"Content-Security-Policy": DATA_CSP
|
|
2072
3176
|
});
|
|
2073
3177
|
};
|
|
2074
|
-
const serveEditor = async (request, response, rest, viewer) => {
|
|
3178
|
+
const serveEditor = async (request, response, rest, viewer, query = "") => {
|
|
2075
3179
|
const parts = rest === "" ? ["index.html"] : rest.split("/");
|
|
2076
3180
|
const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
|
|
2077
3181
|
const realDist = invalid ? null : await fsp2.realpath(EDITOR_DIR).catch(() => null);
|
|
2078
|
-
const file = realDist === null ? null :
|
|
3182
|
+
const file = realDist === null ? null : path6.join(realDist, ...parts);
|
|
2079
3183
|
const real = file === null ? null : await fsp2.realpath(file).catch(() => null);
|
|
2080
3184
|
const info = real === null ? null : await fsp2.lstat(real).catch(() => null);
|
|
2081
3185
|
if (file === null || real !== file || !info?.isFile()) {
|
|
@@ -2086,7 +3190,7 @@ data: ${JSON.stringify(event)}
|
|
|
2086
3190
|
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
2087
3191
|
return;
|
|
2088
3192
|
}
|
|
2089
|
-
const ext =
|
|
3193
|
+
const ext = path6.extname(file).toLowerCase();
|
|
2090
3194
|
const type = STATIC_TYPES[ext] ?? "application/octet-stream";
|
|
2091
3195
|
if (ext === ".html") {
|
|
2092
3196
|
const key = parts.join("/");
|
|
@@ -2096,7 +3200,7 @@ data: ${JSON.stringify(event)}
|
|
|
2096
3200
|
page = { mtimeMs: info.mtimeMs, html, csp: pageCsp(pageScriptSrc(html)) };
|
|
2097
3201
|
pageCache.set(key, page);
|
|
2098
3202
|
}
|
|
2099
|
-
const body = Buffer.from(share.injectHead(page.html, viewer) ?? page.html);
|
|
3203
|
+
const body = Buffer.from(share.injectHead(page.html, viewer, query) ?? page.html);
|
|
2100
3204
|
response.writeHead(200, {
|
|
2101
3205
|
"Content-Type": type,
|
|
2102
3206
|
"Content-Length": body.length,
|
|
@@ -2116,29 +3220,40 @@ data: ${JSON.stringify(event)}
|
|
|
2116
3220
|
"X-Content-Type-Options": "nosniff"
|
|
2117
3221
|
});
|
|
2118
3222
|
};
|
|
2119
|
-
const publish = async (request, response) => {
|
|
3223
|
+
const publish = async (request, response, viewer) => {
|
|
2120
3224
|
if (!/^application\/json\b/i.test(request.headers["content-type"] ?? "")) {
|
|
2121
3225
|
publishError(response, 415, "invalid_content", "expected application/json");
|
|
2122
3226
|
return;
|
|
2123
3227
|
}
|
|
2124
3228
|
const raw = await readBody(request, MAX_PUBLISH_BYTES);
|
|
2125
3229
|
let files;
|
|
3230
|
+
let over = [];
|
|
3231
|
+
let merged = [];
|
|
3232
|
+
const named = (list) => Array.isArray(list) ? list.filter((o) => typeof o?.path === "string" && typeof o.sha === "string" && SHA256.test(o.sha)).map((o) => ({ path: o.path, sha: o.sha, ...typeof o.base === "string" && SHA256.test(o.base) ? { base: o.base } : {} })) : [];
|
|
2126
3233
|
try {
|
|
2127
3234
|
const body = JSON.parse(raw.toString("utf8"));
|
|
2128
3235
|
if (typeof body?.files !== "object" || body.files === null || Array.isArray(body.files)) throw new Error("files");
|
|
2129
3236
|
if (!Object.values(body.files).every(isWireEntry)) throw new Error("entry");
|
|
2130
3237
|
files = body.files;
|
|
3238
|
+
over = named(body.over);
|
|
3239
|
+
merged = named(body.merged);
|
|
2131
3240
|
} catch {
|
|
2132
3241
|
publishError(response, 400, "invalid_content", "malformed publish request");
|
|
2133
3242
|
return;
|
|
2134
3243
|
}
|
|
2135
|
-
const outcome = await store.publish(files);
|
|
3244
|
+
const outcome = await store.publish(files, whoOf(viewer), over, merged);
|
|
2136
3245
|
const names = Object.keys(files).map((p) => p.replace(/^project\//, ""));
|
|
2137
|
-
|
|
3246
|
+
const made = outcome.version ? ` as version ${outcome.version.n}` : "";
|
|
3247
|
+
stamp(`saved from the page by ${viewer.user.name} (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""})${made}: ${names.join(", ")}`);
|
|
2138
3248
|
sendJson2(response, outcome.status, outcome.body);
|
|
2139
3249
|
if (outcome.status === 200 && clients.size > 0) {
|
|
2140
3250
|
const from = String(request.headers["x-deck-client"] ?? "");
|
|
2141
|
-
|
|
3251
|
+
const shas = outcome.body.shas ?? {};
|
|
3252
|
+
const paths = Object.keys(files);
|
|
3253
|
+
changed(
|
|
3254
|
+
{ type: "external-change", paths, ver: String(outcome.body.version ?? ""), files: Object.fromEntries(paths.map((p) => [p, shas[p] ?? null])), by: authorOf(whoOf(viewer)) },
|
|
3255
|
+
CLIENT_ID.test(from) ? from : void 0
|
|
3256
|
+
);
|
|
2142
3257
|
}
|
|
2143
3258
|
};
|
|
2144
3259
|
const upload = async (request, response) => {
|
|
@@ -2158,8 +3273,87 @@ data: ${JSON.stringify(event)}
|
|
|
2158
3273
|
if (viewer.guest && "userName" in extras) extras.userName = viewer.user.name;
|
|
2159
3274
|
return { canEdit: canEditAs(viewer.role), ...extras, role: viewer.role };
|
|
2160
3275
|
};
|
|
3276
|
+
const previewRoute = async (request, response, n, rest, viewer) => {
|
|
3277
|
+
const method = request.method ?? "GET";
|
|
3278
|
+
const state = history.stateAt(n);
|
|
3279
|
+
if (!state) {
|
|
3280
|
+
request.resume();
|
|
3281
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end(`There is no version ${n} (it may be too old to keep).`);
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
if (rest === ROUTES.publish || rest === ROUTES.assets) {
|
|
3285
|
+
request.resume();
|
|
3286
|
+
publishError(response, 403, "not_writer", "This is an earlier version: open the current one to edit.");
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
|
|
3290
|
+
if (method !== "GET") {
|
|
3291
|
+
request.resume();
|
|
3292
|
+
publishError(response, 403, "not_writer", "This is an earlier version: comment on the current one.");
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
return comments.handle(request, response, rest.slice(COMMENTS_ROUTE.length));
|
|
3296
|
+
}
|
|
3297
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
3298
|
+
response.writeHead(405, { Allow: "GET, HEAD" }).end();
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
3301
|
+
if (rest === ROUTES.listing) {
|
|
3302
|
+
const files = {};
|
|
3303
|
+
for (const [p, sha] of [...state].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
3304
|
+
if (store.fileOf(p) === null) continue;
|
|
3305
|
+
files[p] = { sha256: sha, size: history.size(sha) ?? 0, contentType: contentTypeOf(p) };
|
|
3306
|
+
}
|
|
3307
|
+
return sendJson2(response, 200, { ver: `history-${n}`, files });
|
|
3308
|
+
}
|
|
3309
|
+
if (rest === ROUTES.session) return sendJson2(response, 200, { ...session(viewer), canEdit: false });
|
|
3310
|
+
if (rest === ROUTES.events) {
|
|
3311
|
+
response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", "X-Accel-Buffering": "no" });
|
|
3312
|
+
response.write(": connected\n\n");
|
|
3313
|
+
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
|
|
3314
|
+
keepAlive.unref?.();
|
|
3315
|
+
request.on("close", () => clearInterval(keepAlive));
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
if (rest.startsWith("project/")) {
|
|
3319
|
+
const sha = store.fileOf(rest) === null ? void 0 : state.get(rest);
|
|
3320
|
+
const bytes = sha === void 0 ? null : history.read(sha);
|
|
3321
|
+
if (bytes === null) {
|
|
3322
|
+
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
response.writeHead(200, {
|
|
3326
|
+
"Content-Type": contentTypeOf(rest),
|
|
3327
|
+
"Content-Length": bytes.byteLength,
|
|
3328
|
+
"Cache-Control": "no-store",
|
|
3329
|
+
"X-Content-Type-Options": "nosniff",
|
|
3330
|
+
"Content-Security-Policy": DATA_CSP
|
|
3331
|
+
});
|
|
3332
|
+
response.end(method === "HEAD" ? void 0 : bytes);
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
const blob = BLOB_PATH.exec(rest);
|
|
3336
|
+
if (blob) return serveBlob(request, response, blob[1]);
|
|
3337
|
+
return serveEditor(request, response, rest, viewer, `?at=${n}`);
|
|
3338
|
+
};
|
|
2161
3339
|
const route = async (request, response, rest, viewer) => {
|
|
2162
3340
|
const method = request.method ?? "GET";
|
|
3341
|
+
const preview = PREVIEW_PATH.exec(rest);
|
|
3342
|
+
if (preview) {
|
|
3343
|
+
if (preview[2] === void 0) {
|
|
3344
|
+
request.resume();
|
|
3345
|
+
response.writeHead(308, { Location: `${editorPath}${rest}/`, "Cache-Control": "no-store" }).end();
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
return previewRoute(request, response, Number(preview[1]), preview[2].slice(1), viewer);
|
|
3349
|
+
}
|
|
3350
|
+
if (rest === PRESENCE_ROUTE) return presence.handle(request, response, viewer);
|
|
3351
|
+
if (rest === HISTORY_ROUTE || rest.startsWith(`${HISTORY_ROUTE}/`)) {
|
|
3352
|
+
if (await historyHost.handle(request, response, rest.slice(HISTORY_ROUTE.length), viewer)) return;
|
|
3353
|
+
request.resume();
|
|
3354
|
+
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
2163
3357
|
if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
|
|
2164
3358
|
const sub = rest.slice(COMMENTS_ROUTE.length);
|
|
2165
3359
|
if (sub === "/events") share.hold(viewer, response);
|
|
@@ -2180,7 +3374,7 @@ data: ${JSON.stringify(event)}
|
|
|
2180
3374
|
publishError(response, 403, "not_writer", `You can view this ${NOUN} but not edit it.`);
|
|
2181
3375
|
return;
|
|
2182
3376
|
}
|
|
2183
|
-
if (rest === ROUTES.publish) await publish(request, response);
|
|
3377
|
+
if (rest === ROUTES.publish) await publish(request, response, viewer);
|
|
2184
3378
|
else await upload(request, response);
|
|
2185
3379
|
return;
|
|
2186
3380
|
}
|
|
@@ -2271,6 +3465,7 @@ data: ${JSON.stringify(event)}
|
|
|
2271
3465
|
stopWatch();
|
|
2272
3466
|
comments.close();
|
|
2273
3467
|
share.close();
|
|
3468
|
+
presence.close();
|
|
2274
3469
|
for (const client of clients.keys()) client.end();
|
|
2275
3470
|
clients.clear();
|
|
2276
3471
|
server2.close();
|
|
@@ -2287,11 +3482,11 @@ data: ${JSON.stringify(event)}
|
|
|
2287
3482
|
}
|
|
2288
3483
|
function artifactTitle(root, projectDir) {
|
|
2289
3484
|
try {
|
|
2290
|
-
const index = JSON.parse(
|
|
3485
|
+
const index = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE), "utf8"));
|
|
2291
3486
|
for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
|
|
2292
3487
|
} catch {
|
|
2293
3488
|
}
|
|
2294
|
-
return
|
|
3489
|
+
return path6.basename(root);
|
|
2295
3490
|
}
|
|
2296
3491
|
var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
|
|
2297
3492
|
async function serveForeground({ root, port, host, agentHook, lan, portGiven }) {
|
|
@@ -2312,18 +3507,18 @@ async function main(argv) {
|
|
|
2312
3507
|
}
|
|
2313
3508
|
if (positional.length !== 1 || flags.has("help")) {
|
|
2314
3509
|
process.stderr.write(
|
|
2315
|
-
`Usage: node ${
|
|
3510
|
+
`Usage: node ${path6.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
|
|
2316
3511
|
<root> holds project/${INDEX_FILE}.
|
|
2317
3512
|
`
|
|
2318
3513
|
);
|
|
2319
3514
|
return 2;
|
|
2320
3515
|
}
|
|
2321
|
-
let root =
|
|
2322
|
-
if (
|
|
2323
|
-
root =
|
|
3516
|
+
let root = path6.resolve(positional[0]);
|
|
3517
|
+
if (path6.basename(root) === "project" && fs7.existsSync(path6.join(root, INDEX_FILE)) && !fs7.existsSync(path6.join(root, "project"))) {
|
|
3518
|
+
root = path6.dirname(root);
|
|
2324
3519
|
}
|
|
2325
|
-
if (!
|
|
2326
|
-
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${
|
|
3520
|
+
if (!fs7.statSync(path6.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
|
|
3521
|
+
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path6.join(root, "project", INDEX_FILE)} does not exist.` })}
|
|
2327
3522
|
`);
|
|
2328
3523
|
return 2;
|
|
2329
3524
|
}
|
|
@@ -2347,7 +3542,7 @@ async function main(argv) {
|
|
|
2347
3542
|
}
|
|
2348
3543
|
function isMain(moduleUrl) {
|
|
2349
3544
|
try {
|
|
2350
|
-
return Boolean(process.argv[1]) && pathToFileURL(
|
|
3545
|
+
return Boolean(process.argv[1]) && pathToFileURL(fs7.realpathSync(process.argv[1])).href === moduleUrl;
|
|
2351
3546
|
} catch {
|
|
2352
3547
|
return false;
|
|
2353
3548
|
}
|
|
@@ -2360,8 +3555,10 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
2360
3555
|
pageScriptSrc = kind.pageScriptSrc;
|
|
2361
3556
|
sessionExtras = kind.sessionExtras;
|
|
2362
3557
|
commentLocation = kind.commentLocation;
|
|
3558
|
+
kindLabels = kind.labels;
|
|
3559
|
+
kindAgentContext = kind.agentContext;
|
|
2363
3560
|
SCRIPT = fileURLToPath(moduleUrl);
|
|
2364
|
-
EDITOR_DIR =
|
|
3561
|
+
EDITOR_DIR = path6.join(path6.dirname(SCRIPT), "editor");
|
|
2365
3562
|
const runIfMain = () => {
|
|
2366
3563
|
if (!isMain(moduleUrl)) return;
|
|
2367
3564
|
main(process.argv.slice(2)).then(
|
|
@@ -2412,15 +3609,45 @@ function commentLocation2(input, projectDir) {
|
|
|
2412
3609
|
let slideId = match[2];
|
|
2413
3610
|
if (!slideId) {
|
|
2414
3611
|
try {
|
|
2415
|
-
const order = JSON.parse(
|
|
3612
|
+
const order = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).order;
|
|
2416
3613
|
if (Array.isArray(order) && typeof order[slide - 1] === "string") slideId = order[slide - 1];
|
|
2417
3614
|
} catch {
|
|
2418
3615
|
}
|
|
2419
3616
|
}
|
|
2420
3617
|
const file = slideId ? projectFile(`slides/${slideId}.html`) : void 0;
|
|
2421
|
-
if (slideId && file &&
|
|
3618
|
+
if (slideId && file && fs8.existsSync(path7.join(projectDir, "slides", `${slideId}.html`))) return { file, slide, slideId };
|
|
2422
3619
|
return { file: "project/deck.json", slide };
|
|
2423
3620
|
}
|
|
3621
|
+
function labels(projectDir) {
|
|
3622
|
+
let order = [];
|
|
3623
|
+
try {
|
|
3624
|
+
const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).order;
|
|
3625
|
+
if (Array.isArray(listed)) order = listed.filter((id) => typeof id === "string");
|
|
3626
|
+
} catch {
|
|
3627
|
+
}
|
|
3628
|
+
return (rel) => {
|
|
3629
|
+
if (rel === INDEX_FILE2) return { en: "Slide order and settings", zh: "\u9875\u9762\u987A\u5E8F\u548C\u8BBE\u7F6E" };
|
|
3630
|
+
const slide = /^slides\/(.+)\.html$/.exec(rel);
|
|
3631
|
+
if (slide) {
|
|
3632
|
+
const at = order.indexOf(slide[1]);
|
|
3633
|
+
return at < 0 ? { en: `Slide ${slide[1]}`, zh: `\u5E7B\u706F\u7247 ${slide[1]}` } : { en: `Slide ${at + 1} \xB7 ${slide[1]}`, zh: `\u7B2C ${at + 1} \u9875 \xB7 ${slide[1]}` };
|
|
3634
|
+
}
|
|
3635
|
+
if (rel.startsWith("ds/")) return { en: `Design system \xB7 ${rel.slice(3)}`, zh: `\u8BBE\u8BA1\u7CFB\u7EDF \xB7 ${rel.slice(3)}` };
|
|
3636
|
+
return null;
|
|
3637
|
+
};
|
|
3638
|
+
}
|
|
3639
|
+
function agentContext(projectDir, rels) {
|
|
3640
|
+
const slideId = [...rels].reverse().map((rel) => /^slides\/(.+)\.html$/.exec(rel)?.[1]).find((id) => id !== void 0);
|
|
3641
|
+
if (!slideId) return null;
|
|
3642
|
+
let order = [];
|
|
3643
|
+
try {
|
|
3644
|
+
const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).order;
|
|
3645
|
+
if (Array.isArray(listed)) order = listed.filter((id) => typeof id === "string");
|
|
3646
|
+
} catch {
|
|
3647
|
+
}
|
|
3648
|
+
const at = order.indexOf(slideId);
|
|
3649
|
+
return { mode: "edit", deck: null, slideId, ...at < 0 ? {} : { slideIndex: at + 1, slideCount: order.length } };
|
|
3650
|
+
}
|
|
2424
3651
|
var server = createArtifactServer(
|
|
2425
3652
|
{
|
|
2426
3653
|
kind: KIND2,
|
|
@@ -2434,7 +3661,9 @@ var server = createArtifactServer(
|
|
|
2434
3661
|
*/
|
|
2435
3662
|
pageScriptSrc: (html) => `script-src 'self' 'unsafe-eval' ${inlineScriptHashes(html).map((h) => `'${h}'`).join(" ")}`.trim(),
|
|
2436
3663
|
sessionExtras: () => ({}),
|
|
2437
|
-
commentLocation: commentLocation2
|
|
3664
|
+
commentLocation: commentLocation2,
|
|
3665
|
+
labels,
|
|
3666
|
+
agentContext
|
|
2438
3667
|
},
|
|
2439
3668
|
import.meta.url
|
|
2440
3669
|
);
|