viteboard 0.1.1 → 0.1.2
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/content/docs/index.md +1 -1
- package/content/docs/product-roadmap.board.json +4 -4
- package/content/machine/board-v1.schema.json +138 -0
- package/content/machine/example.board.json +217 -0
- package/content/machine/guide.md +270 -0
- package/content/machine/llms.txt +31 -0
- package/content/machine/viteboard.json +62 -0
- package/dist/assets/index-D2W76uu9.js +96 -0
- package/dist/assets/index-rMkUjGXP.css +1 -0
- package/dist/index.html +4 -2
- package/package.json +10 -12
- package/vite.config.ts +619 -38
- package/dist/assets/index-C30zRPsW.js +0 -60
- package/dist/assets/index-e5aqtN8W.css +0 -1
- package/index.html +0 -13
- package/src/app/AppController.ts +0 -962
- package/src/app/BoardState.ts +0 -130
- package/src/app/ClipboardService.ts +0 -159
- package/src/app/CommandManager.ts +0 -66
- package/src/app/ElementActions.ts +0 -152
- package/src/app/EmbedRegionSelector.ts +0 -103
- package/src/app/ExportPng.ts +0 -107
- package/src/app/ImageInsertService.ts +0 -141
- package/src/app/KeyboardShortcuts.ts +0 -124
- package/src/app/main.ts +0 -6
- package/src/board/BoardPreview.ts +0 -189
- package/src/board/BoardService.ts +0 -222
- package/src/canvas/CanvasRenderer.ts +0 -253
- package/src/canvas/CanvasSurface.ts +0 -70
- package/src/canvas/HitTester.ts +0 -110
- package/src/canvas/ImageCache.ts +0 -123
- package/src/canvas/PerformanceMonitor.ts +0 -31
- package/src/canvas/RenderScheduler.ts +0 -26
- package/src/canvas/Viewport.ts +0 -77
- package/src/content/ContentApi.ts +0 -258
- package/src/content/Markdown.ts +0 -431
- package/src/content/MarkdownView.ts +0 -70
- package/src/editor/DocEditor.ts +0 -799
- package/src/editor/htmlToMarkdown.ts +0 -333
- package/src/elements/renderElement.ts +0 -598
- package/src/elements/types.ts +0 -119
- package/src/shell/Shell.ts +0 -2948
- package/src/shell/Sidebar.ts +0 -1651
- package/src/storage/AssetStore.ts +0 -86
- package/src/storage/BoardSerializer.ts +0 -114
- package/src/storage/ImportExportService.ts +0 -153
- package/src/storage/IndexedDbStore.ts +0 -92
- package/src/storage/LocalBoardStore.ts +0 -104
- package/src/styles.css +0 -3332
- package/src/templates/handoffTemplate.ts +0 -34
- package/src/templates/helpers.ts +0 -132
- package/src/templates/index.ts +0 -79
- package/src/templates/journeyMapTemplate.ts +0 -52
- package/src/templates/opportunityTreeTemplate.ts +0 -45
- package/src/templates/personaTemplate.ts +0 -41
- package/src/templates/prioritizationMatrixTemplate.ts +0 -44
- package/src/templates/requirementsFlowTemplate.ts +0 -45
- package/src/templates/screenshotReviewTemplate.ts +0 -52
- package/src/templates/systemDiagramTemplate.ts +0 -41
- package/src/templates/workflowTemplate.ts +0 -35
- package/src/testing/StressTestGenerator.ts +0 -134
- package/src/testing/StressTestPanel.ts +0 -64
- package/src/tools/ArrowTool.ts +0 -97
- package/src/tools/ImageTool.ts +0 -39
- package/src/tools/PanTool.ts +0 -34
- package/src/tools/SelectTool.ts +0 -377
- package/src/tools/ShapeTool.ts +0 -106
- package/src/tools/StickyTool.ts +0 -69
- package/src/tools/TaskTool.ts +0 -52
- package/src/tools/TextTool.ts +0 -45
- package/src/tools/ToolContext.ts +0 -51
- package/src/tools/ToolController.ts +0 -178
- package/src/ui/Modal.ts +0 -58
- package/src/ui/PerformanceOverlay.ts +0 -75
- package/src/ui/StorageManager.ts +0 -94
- package/src/ui/TemplatePicker.ts +0 -67
- package/src/ui/TextEditorOverlay.ts +0 -262
- package/src/ui/Toolbar.ts +0 -189
- package/src/ui/TopControls.ts +0 -137
- package/src/ui/icons.ts +0 -56
- package/tsconfig.json +0 -19
package/vite.config.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { defineConfig, type Plugin, type Connect } from "vite";
|
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
8
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
8
10
|
|
|
9
11
|
const WORKSPACE_DIR = ".workspace";
|
|
10
12
|
const WORKSPACE_FILE = "workspace.json";
|
|
@@ -29,6 +31,18 @@ const MIME: Record<string, string> = {
|
|
|
29
31
|
".json": "application/json; charset=utf-8",
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
const MIME_EXT: Record<string, string> = {
|
|
35
|
+
"image/png": ".png",
|
|
36
|
+
"image/jpeg": ".jpg",
|
|
37
|
+
"image/webp": ".webp",
|
|
38
|
+
"image/gif": ".gif",
|
|
39
|
+
"image/svg+xml": ".svg",
|
|
40
|
+
};
|
|
41
|
+
const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
|
|
42
|
+
const ACTOR_TTL_MS = 30000;
|
|
43
|
+
const LOCK_TTL_MS = 30000;
|
|
44
|
+
const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
|
|
45
|
+
|
|
32
46
|
const execFileAsync = promisify(execFile);
|
|
33
47
|
|
|
34
48
|
type WorkspaceManifest = {
|
|
@@ -59,6 +73,7 @@ type WorkspaceResponse = {
|
|
|
59
73
|
projects: Array<{
|
|
60
74
|
id: string;
|
|
61
75
|
label: string;
|
|
76
|
+
rootPath: string;
|
|
62
77
|
docsRoots: Array<{
|
|
63
78
|
id: string;
|
|
64
79
|
projectId: string;
|
|
@@ -73,10 +88,51 @@ type TreeNode =
|
|
|
73
88
|
| { type: "file"; name: string; path: string }
|
|
74
89
|
| { type: "dir"; name: string; path: string; children: TreeNode[] };
|
|
75
90
|
|
|
91
|
+
type ActorState =
|
|
92
|
+
| "idle"
|
|
93
|
+
| "viewing-doc"
|
|
94
|
+
| "editing-doc"
|
|
95
|
+
| "viewing-board"
|
|
96
|
+
| "editing-board"
|
|
97
|
+
| "viewing-project"
|
|
98
|
+
| "writing";
|
|
99
|
+
|
|
100
|
+
type ActorSessionRecord = {
|
|
101
|
+
actorId: string;
|
|
102
|
+
actorType: "human" | "agent";
|
|
103
|
+
label: string;
|
|
104
|
+
route: string;
|
|
105
|
+
state: ActorState;
|
|
106
|
+
rootId?: string;
|
|
107
|
+
projectId?: string;
|
|
108
|
+
docPath?: string;
|
|
109
|
+
boardId?: string;
|
|
110
|
+
updatedAt: number;
|
|
111
|
+
expiresAt: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type LockRecord = {
|
|
115
|
+
lockId: string;
|
|
116
|
+
actorId: string;
|
|
117
|
+
actorType: "human" | "agent";
|
|
118
|
+
label: string;
|
|
119
|
+
targetType: "doc" | "board";
|
|
120
|
+
rootId: string;
|
|
121
|
+
path?: string;
|
|
122
|
+
boardId?: string;
|
|
123
|
+
acquiredAt: number;
|
|
124
|
+
expiresAt: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
76
127
|
function contentApiPlugin(): Plugin {
|
|
77
128
|
const cwd = process.cwd();
|
|
129
|
+
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
78
130
|
const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
|
|
79
131
|
const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
|
|
132
|
+
const machineDir = path.join(packageRoot, "content", "machine");
|
|
133
|
+
const actorSessions = new Map<string, ActorSessionRecord>();
|
|
134
|
+
const locks = new Map<string, LockRecord>();
|
|
135
|
+
const sseClients = new Set<ServerResponse>();
|
|
80
136
|
|
|
81
137
|
function send(res: ServerResponse, status: number, body: unknown): void {
|
|
82
138
|
res.statusCode = status;
|
|
@@ -90,6 +146,87 @@ function contentApiPlugin(): Plugin {
|
|
|
90
146
|
res.end(body);
|
|
91
147
|
}
|
|
92
148
|
|
|
149
|
+
function sendSse(res: ServerResponse, event: string, body: unknown): void {
|
|
150
|
+
res.write(`event: ${event}\n`);
|
|
151
|
+
res.write(`data: ${JSON.stringify(body)}\n\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function broadcast(event: string, body: unknown): void {
|
|
155
|
+
for (const client of sseClients) {
|
|
156
|
+
try {
|
|
157
|
+
sendSse(client, event, body);
|
|
158
|
+
} catch {
|
|
159
|
+
sseClients.delete(client);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function actorFromHeaders(req: IncomingMessage): { actorId: string; actorType: "human" | "agent"; label: string } | null {
|
|
165
|
+
const actorId = String(req.headers["x-viteboard-actor-id"] ?? "").trim();
|
|
166
|
+
if (!actorId) return null;
|
|
167
|
+
const actorType = String(req.headers["x-viteboard-actor-type"] ?? "human") === "agent" ? "agent" : "human";
|
|
168
|
+
const label = String(req.headers["x-viteboard-actor-label"] ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
|
|
169
|
+
return { actorId, actorType, label };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function lockKey(target: { targetType: "doc" | "board"; rootId: string; path?: string; boardId?: string }): string {
|
|
173
|
+
const suffix = target.targetType === "doc" ? target.path ?? "" : target.boardId ?? "";
|
|
174
|
+
return `${target.targetType}:${target.rootId}:${suffix}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function activeActors(): ActorSessionRecord[] {
|
|
178
|
+
return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function activeLocks(): LockRecord[] {
|
|
182
|
+
return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function broadcastPresence(): void {
|
|
186
|
+
broadcast("presence", { actors: activeActors(), locks: activeLocks() });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function broadcastLocks(): void {
|
|
190
|
+
broadcast("lock-changed", { locks: activeLocks() });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function pruneEphemeral(): void {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
let changedPresence = false;
|
|
196
|
+
let changedLocks = false;
|
|
197
|
+
for (const [actorId, actor] of actorSessions) {
|
|
198
|
+
if (actor.expiresAt <= now) {
|
|
199
|
+
actorSessions.delete(actorId);
|
|
200
|
+
changedPresence = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const [key, lock] of locks) {
|
|
204
|
+
if (lock.expiresAt <= now || !actorSessions.has(lock.actorId)) {
|
|
205
|
+
locks.delete(key);
|
|
206
|
+
changedLocks = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (changedPresence) broadcastPresence();
|
|
210
|
+
else if (changedLocks) broadcastLocks();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function extendActorLocks(actorId: string): void {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
for (const lock of locks.values()) {
|
|
216
|
+
if (lock.actorId === actorId) lock.expiresAt = now + LOCK_TTL_MS;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readMachineAsset(fileName: string): Promise<string> {
|
|
221
|
+
return fs.readFile(path.join(machineDir, fileName), "utf-8");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function revisionForText(content: string): string {
|
|
225
|
+
return createHash("sha1").update(content).digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setInterval(pruneEphemeral, 10000).unref?.();
|
|
229
|
+
|
|
93
230
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
94
231
|
return new Promise((resolve, reject) => {
|
|
95
232
|
let data = "";
|
|
@@ -114,6 +251,48 @@ function contentApiPlugin(): Plugin {
|
|
|
114
251
|
return (hash >>> 0).toString(36);
|
|
115
252
|
}
|
|
116
253
|
|
|
254
|
+
async function canonicalizeProjectRoot(rootPath: string): Promise<string> {
|
|
255
|
+
const resolved = path.resolve(rootPath);
|
|
256
|
+
try {
|
|
257
|
+
return path.resolve(await fs.realpath(resolved));
|
|
258
|
+
} catch {
|
|
259
|
+
return resolved;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isNestedPath(parent: string, child: string): boolean {
|
|
264
|
+
const rel = path.relative(parent, child);
|
|
265
|
+
return rel !== "" && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function findOverlappingProject(
|
|
269
|
+
projects: WorkspaceProjectRecord[],
|
|
270
|
+
candidateRoot: string,
|
|
271
|
+
ignoreProjectId?: string
|
|
272
|
+
): WorkspaceProjectRecord | null {
|
|
273
|
+
for (const project of projects) {
|
|
274
|
+
if (ignoreProjectId && project.id === ignoreProjectId) continue;
|
|
275
|
+
if (
|
|
276
|
+
project.rootPath === candidateRoot ||
|
|
277
|
+
isNestedPath(project.rootPath, candidateRoot) ||
|
|
278
|
+
isNestedPath(candidateRoot, project.rootPath)
|
|
279
|
+
) {
|
|
280
|
+
return project;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function overlapError(existing: WorkspaceProjectRecord, candidateRoot: string): string {
|
|
287
|
+
if (existing.rootPath === candidateRoot) {
|
|
288
|
+
return `\"${existing.label}\" is already added. Pick a different folder, or remove the existing project first.`;
|
|
289
|
+
}
|
|
290
|
+
if (isNestedPath(existing.rootPath, candidateRoot)) {
|
|
291
|
+
return `That folder is inside the existing workspace project \"${existing.label}\". ViteBoard only allows one project root per nested path.`;
|
|
292
|
+
}
|
|
293
|
+
return `That folder contains the existing workspace project \"${existing.label}\". Add the more specific project instead, or remove the current one first.`;
|
|
294
|
+
}
|
|
295
|
+
|
|
117
296
|
async function ensureWorkspaceDir(): Promise<void> {
|
|
118
297
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
119
298
|
}
|
|
@@ -122,17 +301,26 @@ function contentApiPlugin(): Plugin {
|
|
|
122
301
|
try {
|
|
123
302
|
const raw = await fs.readFile(workspaceFile, "utf-8");
|
|
124
303
|
const parsed = JSON.parse(raw) as Partial<WorkspaceManifest>;
|
|
125
|
-
const
|
|
126
|
-
? parsed.projects
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
130
|
-
.map((item) => ({
|
|
131
|
-
id: item.id,
|
|
132
|
-
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(item.rootPath),
|
|
133
|
-
rootPath: path.resolve(item.rootPath),
|
|
134
|
-
}))
|
|
304
|
+
const rawProjects = Array.isArray(parsed.projects)
|
|
305
|
+
? parsed.projects.filter((item): item is WorkspaceProjectRecord => {
|
|
306
|
+
return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
|
|
307
|
+
})
|
|
135
308
|
: [];
|
|
309
|
+
const projects: WorkspaceProjectRecord[] = [];
|
|
310
|
+
const seenIds = new Set<string>();
|
|
311
|
+
const seenPaths = new Set<string>();
|
|
312
|
+
for (const item of rawProjects) {
|
|
313
|
+
const rootPath = await canonicalizeProjectRoot(item.rootPath);
|
|
314
|
+
const id = item.id.trim() || `project-${hashPath(rootPath)}`;
|
|
315
|
+
if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
|
|
316
|
+
seenIds.add(id);
|
|
317
|
+
seenPaths.add(rootPath);
|
|
318
|
+
projects.push({
|
|
319
|
+
id,
|
|
320
|
+
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
|
|
321
|
+
rootPath,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
136
324
|
return { version: 1, projects };
|
|
137
325
|
} catch {
|
|
138
326
|
return { version: 1, projects: [] };
|
|
@@ -153,10 +341,7 @@ function contentApiPlugin(): Plugin {
|
|
|
153
341
|
}
|
|
154
342
|
|
|
155
343
|
async function loadProjects(): Promise<WorkspaceProjectRecord[]> {
|
|
156
|
-
|
|
157
|
-
return manifest.projects.filter((item, index, list) => {
|
|
158
|
-
return list.findIndex((other) => other.rootPath === item.rootPath) === index;
|
|
159
|
-
});
|
|
344
|
+
return (await loadManifest()).projects;
|
|
160
345
|
}
|
|
161
346
|
|
|
162
347
|
async function openProjectPicker(): Promise<string | null> {
|
|
@@ -290,6 +475,7 @@ function contentApiPlugin(): Plugin {
|
|
|
290
475
|
projects: projects.map((project) => ({
|
|
291
476
|
id: project.id,
|
|
292
477
|
label: project.label,
|
|
478
|
+
rootPath: project.rootPath,
|
|
293
479
|
docsRoots: project.docsRoots.map((root) => ({
|
|
294
480
|
id: root.id,
|
|
295
481
|
projectId: root.projectId,
|
|
@@ -323,6 +509,142 @@ function contentApiPlugin(): Plugin {
|
|
|
323
509
|
return abs;
|
|
324
510
|
}
|
|
325
511
|
|
|
512
|
+
function normalizeRelativePath(input: string): string {
|
|
513
|
+
const parts: string[] = [];
|
|
514
|
+
for (const raw of String(input ?? "").replace(/\\/g, "/").split("/")) {
|
|
515
|
+
const part = raw.trim();
|
|
516
|
+
if (!part || part === ".") continue;
|
|
517
|
+
if (part === "..") {
|
|
518
|
+
if (parts.length > 0) parts.pop();
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
parts.push(part);
|
|
522
|
+
}
|
|
523
|
+
return parts.join("/");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function dirnamePosix(filePath: string): string {
|
|
527
|
+
const normalized = normalizeRelativePath(filePath);
|
|
528
|
+
const idx = normalized.lastIndexOf("/");
|
|
529
|
+
return idx === -1 ? "" : normalized.slice(0, idx);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveDocRelativePath(fromFile: string, target: string): string | null {
|
|
533
|
+
const raw = String(target ?? "").trim();
|
|
534
|
+
if (!raw || /^(https?:|mailto:|tel:|#|data:)/i.test(raw)) return null;
|
|
535
|
+
if (raw.startsWith("/")) return normalizeRelativePath(raw);
|
|
536
|
+
const base = dirnamePosix(fromFile);
|
|
537
|
+
return normalizeRelativePath(base ? `${base}/${raw}` : raw);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function isManagedAssetPath(relPath: string): boolean {
|
|
541
|
+
return MANAGED_ASSET_RE.test(normalizeRelativePath(relPath));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function collectFiles(dir: string, base = ""): Promise<string[]> {
|
|
545
|
+
let entries: import("node:fs").Dirent[];
|
|
546
|
+
try {
|
|
547
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const files: string[] = [];
|
|
553
|
+
for (const entry of entries) {
|
|
554
|
+
if (entry.name.startsWith(".")) continue;
|
|
555
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
556
|
+
const abs = path.join(dir, entry.name);
|
|
557
|
+
if (entry.isDirectory()) {
|
|
558
|
+
files.push(...(await collectFiles(abs, rel)));
|
|
559
|
+
} else {
|
|
560
|
+
files.push(rel);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return files;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function collectBoardAssetRefs(raw: unknown): Set<string> {
|
|
567
|
+
const refs = new Set<string>();
|
|
568
|
+
if (typeof raw !== "object" || raw === null) return refs;
|
|
569
|
+
const elements = Array.isArray((raw as { elements?: unknown[] }).elements)
|
|
570
|
+
? (raw as { elements: unknown[] }).elements
|
|
571
|
+
: [];
|
|
572
|
+
for (const element of elements) {
|
|
573
|
+
if (typeof element !== "object" || element === null) continue;
|
|
574
|
+
const data = (element as { data?: Record<string, unknown> }).data;
|
|
575
|
+
const src = typeof data?.src === "string" ? normalizeRelativePath(data.src) : "";
|
|
576
|
+
if (isManagedAssetPath(src)) refs.add(src);
|
|
577
|
+
}
|
|
578
|
+
return refs;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function collectMarkdownAssetRefs(markdown: string, docPath: string): Set<string> {
|
|
582
|
+
const refs = new Set<string>();
|
|
583
|
+
const imagePatterns = [
|
|
584
|
+
/!\[[^\]]*\]\(([^)\s]+)\)/g,
|
|
585
|
+
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
for (const pattern of imagePatterns) {
|
|
589
|
+
pattern.lastIndex = 0;
|
|
590
|
+
let match: RegExpExecArray | null;
|
|
591
|
+
while ((match = pattern.exec(markdown))) {
|
|
592
|
+
const resolved = resolveDocRelativePath(docPath, match[1]);
|
|
593
|
+
if (resolved && isManagedAssetPath(resolved)) refs.add(resolved);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return refs;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function cleanupManagedAssets(rootAbsPath: string, preservePaths: string[] = []): Promise<number> {
|
|
601
|
+
const allFiles = await collectFiles(rootAbsPath);
|
|
602
|
+
const managedAssets = allFiles.filter((file) => isManagedAssetPath(file));
|
|
603
|
+
if (managedAssets.length === 0) return 0;
|
|
604
|
+
|
|
605
|
+
const referenced = new Set<string>();
|
|
606
|
+
for (const relPath of allFiles) {
|
|
607
|
+
const abs = safeResolve(rootAbsPath, relPath);
|
|
608
|
+
if (!abs) continue;
|
|
609
|
+
if (relPath.endsWith(".board.json")) {
|
|
610
|
+
try {
|
|
611
|
+
const raw = JSON.parse(await fs.readFile(abs, "utf-8"));
|
|
612
|
+
for (const ref of collectBoardAssetRefs(raw)) referenced.add(ref);
|
|
613
|
+
} catch {
|
|
614
|
+
// Ignore unreadable or invalid boards; cleanup stays conservative.
|
|
615
|
+
}
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (relPath.endsWith(".md")) {
|
|
619
|
+
try {
|
|
620
|
+
const markdown = await fs.readFile(abs, "utf-8");
|
|
621
|
+
for (const ref of collectMarkdownAssetRefs(markdown, relPath)) referenced.add(ref);
|
|
622
|
+
} catch {
|
|
623
|
+
// Ignore unreadable docs; cleanup stays conservative.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for (const relPath of preservePaths) {
|
|
629
|
+
const normalized = normalizeRelativePath(relPath);
|
|
630
|
+
if (isManagedAssetPath(normalized)) referenced.add(normalized);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let removed = 0;
|
|
634
|
+
for (const relPath of managedAssets) {
|
|
635
|
+
if (referenced.has(relPath)) continue;
|
|
636
|
+
const abs = safeResolve(rootAbsPath, relPath);
|
|
637
|
+
if (!abs) continue;
|
|
638
|
+
try {
|
|
639
|
+
await fs.unlink(abs);
|
|
640
|
+
removed++;
|
|
641
|
+
} catch (err) {
|
|
642
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return removed;
|
|
646
|
+
}
|
|
647
|
+
|
|
326
648
|
async function walkDocsRoot(dir: string, base: string, nestedRoots: Set<string>): Promise<TreeNode[]> {
|
|
327
649
|
let entries: import("node:fs").Dirent[];
|
|
328
650
|
try {
|
|
@@ -389,29 +711,226 @@ function contentApiPlugin(): Plugin {
|
|
|
389
711
|
return nodes;
|
|
390
712
|
}
|
|
391
713
|
|
|
714
|
+
async function sendMachineFile(res: ServerResponse, fileName: string, contentType: string): Promise<void> {
|
|
715
|
+
return sendText(res, 200, await readMachineAsset(fileName), contentType);
|
|
716
|
+
}
|
|
717
|
+
|
|
392
718
|
const handler: Connect.NextHandleFunction = (req, res, next) => {
|
|
393
719
|
const url = req.url ?? "";
|
|
394
|
-
|
|
720
|
+
const parsed = new URL(url, "http://localhost");
|
|
721
|
+
const route = parsed.pathname;
|
|
722
|
+
|
|
723
|
+
if (route === "/" || route === "/index.html") {
|
|
724
|
+
res.setHeader("Link", ROOT_LINK_HEADER);
|
|
725
|
+
return next();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (route === "/llms.txt") {
|
|
729
|
+
void sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (route === "/.well-known/viteboard.json") {
|
|
734
|
+
void sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (route === "/viteboard-guide.md") {
|
|
739
|
+
void sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (route === "/board-v1.schema.json") {
|
|
744
|
+
void sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (route === "/viteboard-example.board.json") {
|
|
749
|
+
void sendMachineFile(res, "example.board.json", "application/json; charset=utf-8");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (!route.startsWith("/__api/")) return next();
|
|
395
754
|
|
|
396
755
|
void (async () => {
|
|
397
756
|
try {
|
|
398
|
-
const parsed = new URL(url, "http://localhost");
|
|
399
|
-
const route = parsed.pathname;
|
|
400
757
|
const method = (req.method ?? "GET").toUpperCase();
|
|
758
|
+
const actor = actorFromHeaders(req);
|
|
759
|
+
pruneEphemeral();
|
|
401
760
|
|
|
402
761
|
if (route === "/__api/workspace" && method === "GET") {
|
|
403
762
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
404
763
|
}
|
|
405
764
|
|
|
765
|
+
if (route === "/__api/events" && method === "GET") {
|
|
766
|
+
res.statusCode = 200;
|
|
767
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
768
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
769
|
+
res.setHeader("Connection", "keep-alive");
|
|
770
|
+
res.write(": connected\n\n");
|
|
771
|
+
sseClients.add(res);
|
|
772
|
+
sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
|
|
773
|
+
req.on("close", () => {
|
|
774
|
+
sseClients.delete(res);
|
|
775
|
+
});
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (route === "/__api/actors/session" && method === "GET") {
|
|
780
|
+
return send(res, 200, { actors: activeActors() });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (route === "/__api/actors/session" && method === "POST") {
|
|
784
|
+
const body = JSON.parse(await readBody(req));
|
|
785
|
+
const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
|
|
786
|
+
if (!actorId) return send(res, 400, { error: "actorId is required" });
|
|
787
|
+
const actorType = String(body.actorType ?? actor?.actorType ?? "human") === "agent" ? "agent" : "human";
|
|
788
|
+
const label = String(body.label ?? actor?.label ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
actorSessions.set(actorId, {
|
|
791
|
+
actorId,
|
|
792
|
+
actorType,
|
|
793
|
+
label,
|
|
794
|
+
route: String(body.route ?? "#/"),
|
|
795
|
+
state: String(body.state ?? "idle") as ActorState,
|
|
796
|
+
rootId: typeof body.rootId === "string" ? body.rootId : undefined,
|
|
797
|
+
projectId: typeof body.projectId === "string" ? body.projectId : undefined,
|
|
798
|
+
docPath: typeof body.docPath === "string" ? body.docPath : undefined,
|
|
799
|
+
boardId: typeof body.boardId === "string" ? body.boardId : undefined,
|
|
800
|
+
selectedElementIds: Array.isArray(body.selectedElementIds)
|
|
801
|
+
? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
|
|
802
|
+
: undefined,
|
|
803
|
+
updatedAt: now,
|
|
804
|
+
expiresAt: now + ACTOR_TTL_MS,
|
|
805
|
+
});
|
|
806
|
+
extendActorLocks(actorId);
|
|
807
|
+
broadcastPresence();
|
|
808
|
+
return send(res, 200, { ok: true });
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (route === "/__api/actors/heartbeat" && method === "POST") {
|
|
812
|
+
const body = JSON.parse(await readBody(req));
|
|
813
|
+
const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
|
|
814
|
+
if (!actorId) return send(res, 400, { error: "actorId is required" });
|
|
815
|
+
const current = actorSessions.get(actorId);
|
|
816
|
+
if (!current) return send(res, 404, { error: "Actor session not found" });
|
|
817
|
+
const now = Date.now();
|
|
818
|
+
const next: ActorSessionRecord = {
|
|
819
|
+
...current,
|
|
820
|
+
route: typeof body.route === "string" ? body.route : current.route,
|
|
821
|
+
state: typeof body.state === "string" ? (body.state as ActorState) : current.state,
|
|
822
|
+
rootId: typeof body.rootId === "string" ? body.rootId : current.rootId,
|
|
823
|
+
projectId: typeof body.projectId === "string" ? body.projectId : current.projectId,
|
|
824
|
+
docPath: typeof body.docPath === "string" ? body.docPath : current.docPath,
|
|
825
|
+
boardId: typeof body.boardId === "string" ? body.boardId : current.boardId,
|
|
826
|
+
selectedElementIds: Array.isArray(body.selectedElementIds)
|
|
827
|
+
? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
|
|
828
|
+
: current.selectedElementIds,
|
|
829
|
+
updatedAt: now,
|
|
830
|
+
expiresAt: now + ACTOR_TTL_MS,
|
|
831
|
+
};
|
|
832
|
+
actorSessions.set(actorId, next);
|
|
833
|
+
extendActorLocks(actorId);
|
|
834
|
+
broadcastPresence();
|
|
835
|
+
return send(res, 200, { ok: true });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (route === "/__api/actors/session" && method === "DELETE") {
|
|
839
|
+
const actorId = parsed.searchParams.get("actorId") ?? actor?.actorId ?? "";
|
|
840
|
+
if (!actorId) return send(res, 400, { error: "actorId is required" });
|
|
841
|
+
actorSessions.delete(actorId);
|
|
842
|
+
let changedLocks = false;
|
|
843
|
+
for (const [key, lock] of locks) {
|
|
844
|
+
if (lock.actorId === actorId) {
|
|
845
|
+
locks.delete(key);
|
|
846
|
+
changedLocks = true;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (changedLocks) broadcastLocks();
|
|
850
|
+
broadcastPresence();
|
|
851
|
+
return send(res, 200, { ok: true });
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (route === "/__api/locks" && method === "GET") {
|
|
855
|
+
return send(res, 200, { locks: activeLocks() });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (route === "/__api/locks/acquire" && method === "POST") {
|
|
859
|
+
const body = JSON.parse(await readBody(req));
|
|
860
|
+
if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
|
|
861
|
+
const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
|
|
862
|
+
const rootId = String(body.rootId ?? "").trim();
|
|
863
|
+
if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
|
|
864
|
+
const resolved = await resolveRoot(rootId);
|
|
865
|
+
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
866
|
+
const targetPath = targetType === "doc" ? String(body.path ?? "").trim() : undefined;
|
|
867
|
+
const targetBoardId = targetType === "board" ? String(body.boardId ?? "").trim() : undefined;
|
|
868
|
+
const targetAbs = targetType === "doc"
|
|
869
|
+
? safeResolve(resolved.root.absPath, targetPath ?? "")
|
|
870
|
+
: safeResolve(resolved.root.absPath, `${targetBoardId}.board.json`);
|
|
871
|
+
if (!targetAbs) {
|
|
872
|
+
return send(res, 400, { error: targetType === "doc" ? "Invalid path" : "Invalid boardId" });
|
|
873
|
+
}
|
|
874
|
+
const key = lockKey({ targetType, rootId, path: targetPath, boardId: targetBoardId });
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
const existing = locks.get(key);
|
|
877
|
+
if (existing && existing.actorId !== actor.actorId && existing.expiresAt > now) {
|
|
878
|
+
return send(res, 409, { error: `${existing.label} is editing this ${targetType}.`, lock: existing });
|
|
879
|
+
}
|
|
880
|
+
const nextLock: LockRecord = existing && existing.actorId === actor.actorId
|
|
881
|
+
? { ...existing, expiresAt: now + LOCK_TTL_MS }
|
|
882
|
+
: {
|
|
883
|
+
lockId: `lock-${hashPath(`${key}:${now}`)}`,
|
|
884
|
+
actorId: actor.actorId,
|
|
885
|
+
actorType: actor.actorType,
|
|
886
|
+
label: actor.label,
|
|
887
|
+
targetType,
|
|
888
|
+
rootId,
|
|
889
|
+
path: targetPath,
|
|
890
|
+
boardId: targetBoardId,
|
|
891
|
+
acquiredAt: now,
|
|
892
|
+
expiresAt: now + LOCK_TTL_MS,
|
|
893
|
+
};
|
|
894
|
+
locks.set(key, nextLock);
|
|
895
|
+
broadcastLocks();
|
|
896
|
+
broadcastPresence();
|
|
897
|
+
return send(res, 200, { ok: true, lock: nextLock });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (route === "/__api/locks/release" && method === "POST") {
|
|
901
|
+
const body = JSON.parse(await readBody(req));
|
|
902
|
+
if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
|
|
903
|
+
const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
|
|
904
|
+
const rootId = String(body.rootId ?? "").trim();
|
|
905
|
+
if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
|
|
906
|
+
const key = lockKey({
|
|
907
|
+
targetType,
|
|
908
|
+
rootId,
|
|
909
|
+
path: targetType === "doc" ? String(body.path ?? "").trim() : undefined,
|
|
910
|
+
boardId: targetType === "board" ? String(body.boardId ?? "").trim() : undefined,
|
|
911
|
+
});
|
|
912
|
+
const existing = locks.get(key);
|
|
913
|
+
if (!existing) return send(res, 200, { ok: true });
|
|
914
|
+
if (existing.actorId !== actor.actorId) return send(res, 403, { error: "Lock owned by another actor" });
|
|
915
|
+
locks.delete(key);
|
|
916
|
+
broadcastLocks();
|
|
917
|
+
broadcastPresence();
|
|
918
|
+
return send(res, 200, { ok: true });
|
|
919
|
+
}
|
|
920
|
+
|
|
406
921
|
if (route === "/__api/workspace/project" && method === "POST") {
|
|
407
922
|
const { rootPath, label } = JSON.parse(await readBody(req));
|
|
408
|
-
const absRoot =
|
|
923
|
+
const absRoot = await canonicalizeProjectRoot(String(rootPath ?? "").trim());
|
|
409
924
|
if (!absRoot || !(await dirExists(absRoot))) {
|
|
410
925
|
return send(res, 400, { error: "Project root not found" });
|
|
411
926
|
}
|
|
412
927
|
const manifest = await loadManifest();
|
|
928
|
+
const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
|
|
929
|
+
const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
|
|
930
|
+
const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
|
|
931
|
+
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
413
932
|
const project: WorkspaceProjectRecord = {
|
|
414
|
-
id:
|
|
933
|
+
id: projectId,
|
|
415
934
|
label: String(label ?? "").trim() || path.basename(absRoot) || absRoot,
|
|
416
935
|
rootPath: absRoot,
|
|
417
936
|
};
|
|
@@ -419,17 +938,22 @@ function contentApiPlugin(): Plugin {
|
|
|
419
938
|
if (existingIndex === -1) manifest.projects.push(project);
|
|
420
939
|
else manifest.projects.splice(existingIndex, 1, project);
|
|
421
940
|
await saveManifest(manifest);
|
|
941
|
+
broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
422
942
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
423
943
|
}
|
|
424
944
|
|
|
425
945
|
if (route === "/__api/workspace/pick-project" && method === "POST") {
|
|
426
946
|
const picked = await openProjectPicker();
|
|
427
947
|
if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
428
|
-
const absRoot =
|
|
948
|
+
const absRoot = await canonicalizeProjectRoot(picked);
|
|
429
949
|
if (!(await dirExists(absRoot))) return send(res, 400, { error: "Project root not found" });
|
|
430
950
|
const manifest = await loadManifest();
|
|
951
|
+
const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
|
|
952
|
+
const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
|
|
953
|
+
const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
|
|
954
|
+
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
431
955
|
const project: WorkspaceProjectRecord = {
|
|
432
|
-
id:
|
|
956
|
+
id: projectId,
|
|
433
957
|
label: path.basename(absRoot) || absRoot,
|
|
434
958
|
rootPath: absRoot,
|
|
435
959
|
};
|
|
@@ -437,6 +961,7 @@ function contentApiPlugin(): Plugin {
|
|
|
437
961
|
if (existingIndex === -1) manifest.projects.push(project);
|
|
438
962
|
else manifest.projects.splice(existingIndex, 1, project);
|
|
439
963
|
await saveManifest(manifest);
|
|
964
|
+
broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
440
965
|
return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(await getWorkspace()), addedProjectId: project.id });
|
|
441
966
|
}
|
|
442
967
|
|
|
@@ -452,6 +977,7 @@ function contentApiPlugin(): Plugin {
|
|
|
452
977
|
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath)
|
|
453
978
|
?? refreshedProject?.docsRoots[0];
|
|
454
979
|
if (!root) return send(res, 500, { error: "Could not create docs root" });
|
|
980
|
+
broadcast("workspace-changed", { action: "docs-root-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
|
|
455
981
|
return send(res, 200, {
|
|
456
982
|
root: {
|
|
457
983
|
id: root.id,
|
|
@@ -476,6 +1002,7 @@ function contentApiPlugin(): Plugin {
|
|
|
476
1002
|
const refreshedProject = workspace.find((item) => item.id === project.id);
|
|
477
1003
|
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath);
|
|
478
1004
|
if (!root) return send(res, 500, { error: "Could not create docs area" });
|
|
1005
|
+
broadcast("workspace-changed", { action: "docs-area-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
|
|
479
1006
|
return send(res, 200, {
|
|
480
1007
|
root: {
|
|
481
1008
|
id: root.id,
|
|
@@ -495,6 +1022,7 @@ function contentApiPlugin(): Plugin {
|
|
|
495
1022
|
if (!project) return send(res, 404, { error: "Project not found" });
|
|
496
1023
|
const docsPath = path.join(project.rootPath, String(parentPath ?? ""), "docs");
|
|
497
1024
|
await fs.mkdir(docsPath, { recursive: true });
|
|
1025
|
+
broadcast("workspace-changed", { action: "docs-folder-created", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
498
1026
|
return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
499
1027
|
}
|
|
500
1028
|
|
|
@@ -503,6 +1031,7 @@ function contentApiPlugin(): Plugin {
|
|
|
503
1031
|
const manifest = await loadManifest();
|
|
504
1032
|
manifest.projects = manifest.projects.filter((item) => item.id !== id);
|
|
505
1033
|
await saveManifest(manifest);
|
|
1034
|
+
broadcast("workspace-changed", { action: "project-removed", projectId: id, actorId: actor?.actorId ?? null });
|
|
506
1035
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
507
1036
|
}
|
|
508
1037
|
|
|
@@ -532,21 +1061,38 @@ function contentApiPlugin(): Plugin {
|
|
|
532
1061
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
533
1062
|
try {
|
|
534
1063
|
const content = await fs.readFile(abs, "utf-8");
|
|
535
|
-
return send(res, 200, { path: rel, content });
|
|
1064
|
+
return send(res, 200, { path: rel, content, revision: revisionForText(content) });
|
|
536
1065
|
} catch {
|
|
537
1066
|
return send(res, 404, { error: "Not found" });
|
|
538
1067
|
}
|
|
539
1068
|
}
|
|
540
1069
|
|
|
541
1070
|
if (route === "/__api/file" && method === "PUT") {
|
|
542
|
-
const { rootId, path: rel, content } = JSON.parse(await readBody(req));
|
|
1071
|
+
const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
|
|
543
1072
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
544
1073
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
545
1074
|
const abs = safeResolve(resolved.root.absPath, String(rel));
|
|
546
1075
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
1076
|
+
let currentContent: string | null = null;
|
|
1077
|
+
try {
|
|
1078
|
+
currentContent = await fs.readFile(abs, "utf-8");
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1081
|
+
}
|
|
1082
|
+
const currentRevision = currentContent === null ? null : revisionForText(currentContent);
|
|
1083
|
+
if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
|
|
1084
|
+
return send(res, 409, {
|
|
1085
|
+
error: "File changed on disk",
|
|
1086
|
+
currentRevision,
|
|
1087
|
+
path: rel,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
547
1090
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
548
|
-
|
|
549
|
-
|
|
1091
|
+
const nextContent = String(content ?? "");
|
|
1092
|
+
await fs.writeFile(abs, nextContent, "utf-8");
|
|
1093
|
+
const revision = revisionForText(nextContent);
|
|
1094
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", actorId: actor?.actorId ?? null });
|
|
1095
|
+
return send(res, 200, { ok: true, path: rel, revision });
|
|
550
1096
|
}
|
|
551
1097
|
|
|
552
1098
|
if (route === "/__api/directory" && method === "PUT") {
|
|
@@ -556,6 +1102,7 @@ function contentApiPlugin(): Plugin {
|
|
|
556
1102
|
const abs = safeResolve(resolved.root.absPath, String(rel));
|
|
557
1103
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
558
1104
|
await fs.mkdir(abs, { recursive: true });
|
|
1105
|
+
broadcast("workspace-changed", { action: "directory-created", rootId: resolved.root.id, path: String(rel), actorId: actor?.actorId ?? null });
|
|
559
1106
|
return send(res, 200, { ok: true, path: rel });
|
|
560
1107
|
}
|
|
561
1108
|
|
|
@@ -571,6 +1118,7 @@ function contentApiPlugin(): Plugin {
|
|
|
571
1118
|
} catch (err) {
|
|
572
1119
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
573
1120
|
}
|
|
1121
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
|
|
574
1122
|
return send(res, 200, { ok: true });
|
|
575
1123
|
}
|
|
576
1124
|
|
|
@@ -591,6 +1139,7 @@ function contentApiPlugin(): Plugin {
|
|
|
591
1139
|
}
|
|
592
1140
|
throw err;
|
|
593
1141
|
}
|
|
1142
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), oldPath: String(oldPath), action: "rename", actorId: actor?.actorId ?? null });
|
|
594
1143
|
return send(res, 200, { ok: true, path: newPath });
|
|
595
1144
|
}
|
|
596
1145
|
|
|
@@ -603,21 +1152,38 @@ function contentApiPlugin(): Plugin {
|
|
|
603
1152
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
604
1153
|
try {
|
|
605
1154
|
const content = await fs.readFile(abs, "utf-8");
|
|
606
|
-
return send(res, 200, { id, doc: JSON.parse(content) });
|
|
1155
|
+
return send(res, 200, { id, doc: JSON.parse(content), revision: revisionForText(content) });
|
|
607
1156
|
} catch {
|
|
608
1157
|
return send(res, 404, { error: "Not found" });
|
|
609
1158
|
}
|
|
610
1159
|
}
|
|
611
1160
|
|
|
612
1161
|
if (route === "/__api/board" && method === "PUT") {
|
|
613
|
-
const { rootId, id, doc } = JSON.parse(await readBody(req));
|
|
1162
|
+
const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
|
|
614
1163
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
615
1164
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
616
1165
|
const abs = safeResolve(resolved.root.absPath, `${String(id)}.board.json`);
|
|
617
1166
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
1167
|
+
let currentContent: string | null = null;
|
|
1168
|
+
try {
|
|
1169
|
+
currentContent = await fs.readFile(abs, "utf-8");
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1172
|
+
}
|
|
1173
|
+
const currentRevision = currentContent === null ? null : revisionForText(currentContent);
|
|
1174
|
+
if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
|
|
1175
|
+
return send(res, 409, {
|
|
1176
|
+
error: "Board changed on disk",
|
|
1177
|
+
currentRevision,
|
|
1178
|
+
id,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
618
1181
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
619
|
-
|
|
620
|
-
|
|
1182
|
+
const nextContent = JSON.stringify(doc, null, 2) + "\n";
|
|
1183
|
+
await fs.writeFile(abs, nextContent, "utf-8");
|
|
1184
|
+
const revision = revisionForText(nextContent);
|
|
1185
|
+
broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: "write", actorId: actor?.actorId ?? null });
|
|
1186
|
+
return send(res, 200, { ok: true, id, revision });
|
|
621
1187
|
}
|
|
622
1188
|
|
|
623
1189
|
if (route === "/__api/board" && method === "DELETE") {
|
|
@@ -632,6 +1198,7 @@ function contentApiPlugin(): Plugin {
|
|
|
632
1198
|
} catch (err) {
|
|
633
1199
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
634
1200
|
}
|
|
1201
|
+
broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
|
|
635
1202
|
return send(res, 200, { ok: true, id });
|
|
636
1203
|
}
|
|
637
1204
|
|
|
@@ -654,23 +1221,37 @@ function contentApiPlugin(): Plugin {
|
|
|
654
1221
|
}
|
|
655
1222
|
|
|
656
1223
|
if (route === "/__api/asset" && method === "POST") {
|
|
657
|
-
const { rootId, name, base64 } = JSON.parse(await readBody(req));
|
|
1224
|
+
const { rootId, name, base64, mimeType } = JSON.parse(await readBody(req));
|
|
658
1225
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
659
1226
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
660
|
-
const
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
.slice(0, 40);
|
|
665
|
-
const unique = `${safeBase || "image"}-${Date.now().toString(36)}${ext}`;
|
|
666
|
-
const rel = `assets/${unique}`;
|
|
1227
|
+
const bytes = Buffer.from(String(base64), "base64");
|
|
1228
|
+
const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
|
|
1229
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
1230
|
+
const rel = `assets/${digest}${ext}`;
|
|
667
1231
|
const abs = safeResolve(resolved.root.absPath, rel);
|
|
668
1232
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
669
1233
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
670
|
-
|
|
1234
|
+
try {
|
|
1235
|
+
await fs.access(abs);
|
|
1236
|
+
} catch {
|
|
1237
|
+
await fs.writeFile(abs, bytes);
|
|
1238
|
+
}
|
|
1239
|
+
broadcast("asset-changed", { rootId: resolved.root.id, path: rel, action: "write", actorId: actor?.actorId ?? null });
|
|
671
1240
|
return send(res, 200, { ok: true, path: rel });
|
|
672
1241
|
}
|
|
673
1242
|
|
|
1243
|
+
if (route === "/__api/asset/cleanup" && method === "POST") {
|
|
1244
|
+
const { rootId, preservePaths } = JSON.parse(await readBody(req));
|
|
1245
|
+
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
1246
|
+
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1247
|
+
const removed = await cleanupManagedAssets(
|
|
1248
|
+
resolved.root.absPath,
|
|
1249
|
+
Array.isArray(preservePaths) ? preservePaths.filter((item): item is string => typeof item === "string") : []
|
|
1250
|
+
);
|
|
1251
|
+
if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
|
|
1252
|
+
return send(res, 200, { ok: true, removed });
|
|
1253
|
+
}
|
|
1254
|
+
|
|
674
1255
|
if (route === "/__api/workspace-file" && method === "GET") {
|
|
675
1256
|
await ensureWorkspaceDir();
|
|
676
1257
|
const manifest = await loadManifest();
|