drafted 1.22.6 → 1.22.7
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/cli/drafted.mjs +32 -3
- package/cli/mail-watch.mjs +1 -1
- package/mcp/server.mjs +44 -11
- package/package.json +1 -1
- package/src/shared/excalidraw.mjs +18 -8
- package/src/shared/test-excalidraw-merge.mjs +4 -5
package/cli/drafted.mjs
CHANGED
|
@@ -285,6 +285,29 @@ function withQuery(base, params) {
|
|
|
285
285
|
return qs ? `${base}?${qs}` : base;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
const UUID_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
289
|
+
|
|
290
|
+
// A share URL pasted from the address bar is the pathname plus an origin the
|
|
291
|
+
// server does not want — strip it so the canonical /o/<org>/... form is what the
|
|
292
|
+
// resolver sees. Without this, `drafted read https://drafted.live/o/...` fell
|
|
293
|
+
// through to frameReadPath as "not a URL" and was rejected.
|
|
294
|
+
function stripUrlOrigin(input) {
|
|
295
|
+
return String(input || '').replace(/^https?:\/\/[^/]+/i, '');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Resolve a --project value (UUID, slug or name) to the project id /api/fs takes.
|
|
299
|
+
// The server's project scope casts the id to uuid and 500s on a slug, so a
|
|
300
|
+
// perfectly reasonable `--project my-project` used to be an Internal server error.
|
|
301
|
+
async function resolveProjectRef(command, ref) {
|
|
302
|
+
if (!ref || UUID_REF_RE.test(ref)) return ref;
|
|
303
|
+
const data = await readApiGet(command, '/api/projects');
|
|
304
|
+
const want = String(ref).toLowerCase();
|
|
305
|
+
const hit = (data.projects || []).find((p) =>
|
|
306
|
+
p.id === ref || [p.slug, p.name].some((v) => String(v || '').toLowerCase() === want));
|
|
307
|
+
if (!hit) failRead(command, `project not found: ${ref} — list them with: drafted projects`);
|
|
308
|
+
return hit.id;
|
|
309
|
+
}
|
|
310
|
+
|
|
288
311
|
// GET a Drafted API path with the stored session and return parsed JSON.
|
|
289
312
|
// Strictly read-only: callers only ever issue GETs through this seam. An
|
|
290
313
|
// optional --org override is forwarded as the X-Drafted-Org header (the server
|
|
@@ -349,8 +372,14 @@ function failRead(command, msg) {
|
|
|
349
372
|
// "this project is empty" when it is not. So split the address here, the way
|
|
350
373
|
// the MCP fs tool does, and refuse anything that does not name a project.
|
|
351
374
|
async function resolveCanonicalPath(command, path, projectId) {
|
|
352
|
-
|
|
353
|
-
|
|
375
|
+
// --project may be a UUID, slug or name; the server resolves scope by UUID only.
|
|
376
|
+
const resolvedProjectId = await resolveProjectRef(command, projectId);
|
|
377
|
+
const cleaned = stripUrlOrigin(path);
|
|
378
|
+
const m = String(cleaned || '').match(/^\/o\/([^/]+)(?:\/(.*))?$/);
|
|
379
|
+
if (!m) {
|
|
380
|
+
// Not an org-scoped address — just carry the resolved project through.
|
|
381
|
+
return { path: cleaned, projectId: resolvedProjectId };
|
|
382
|
+
}
|
|
354
383
|
const org = m[1];
|
|
355
384
|
const segs = (m[2] || '').split('/').filter(Boolean);
|
|
356
385
|
const at = segs.indexOf('projects');
|
|
@@ -371,7 +400,7 @@ async function resolveCanonicalPath(command, path, projectId) {
|
|
|
371
400
|
const folder = [...folderChain, ...tail.slice(0, i)].join('/');
|
|
372
401
|
const hit = inOrg.find(p => named(p, tail[i]) && (i === 0 || (p.folder || '') === folder));
|
|
373
402
|
if (!hit) continue;
|
|
374
|
-
if (
|
|
403
|
+
if (resolvedProjectId && resolvedProjectId !== hit.id) {
|
|
375
404
|
failRead(command, `--project ${projectId} conflicts with the project in the path (${hit.id})`);
|
|
376
405
|
}
|
|
377
406
|
return { path: '/' + tail.slice(i + 1).join('/'), projectId: hit.id };
|
package/cli/mail-watch.mjs
CHANGED
|
@@ -109,7 +109,7 @@ export function buildBrief({ thread, message, userEmail }) {
|
|
|
109
109
|
'Do this, and work only in that project:',
|
|
110
110
|
`1. Name your session: session(action="name", name="mail: ${thread.title.slice(0, 30)}").`,
|
|
111
111
|
`2. Read the project (fs ls/read under ${projectPath}) to understand the context.`,
|
|
112
|
-
`3. Write one frame at ${projectPath}/mail/${thread.id.slice(0, 8)}/reply-${new Date().toISOString().slice(0, 10)}.md, starting with front matter "drafted:status:
|
|
112
|
+
`3. Write one frame at ${projectPath}/mail/${thread.id.slice(0, 8)}/reply-${new Date().toISOString().slice(0, 10)}.md, starting with front matter "drafted:status: in_review": a short summary in your own words (do not paste the email, do not copy addresses), what it asks, what should happen next, and a reply draft.`,
|
|
113
113
|
`4. If a reply should be sent, PROPOSE it; the user approves it in Drafted: action(action="propose", type="send_email", payload=<a JSON object with "from":"${userEmail || '<the user\'s connected Gmail address>'}", "to": the sender's address from the From line, "subject": "Re: " plus the Subject line (unless it already starts with Re:), "body": your draft, "threadId":"${thread.gmailThreadId}"${messageId ? ', and "inReplyTo": the Message-ID line' : ''}>, reason="Reply to ${thread.title.replace(/"/g, "'")}"). If it is refused (for example Gmail is not connected for sending in Drafted), say so in the frame. Never send any other way.`,
|
|
114
114
|
'5. Stop. Leave the thread marked as a new reply; the user clears it after reviewing your work.',
|
|
115
115
|
'',
|
package/mcp/server.mjs
CHANGED
|
@@ -490,7 +490,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
490
490
|
screenshot: { title: 'Screenshot', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Render a PNG via headless browser. `scope=frame` for a single frame, `scope=canvas` for a region of the project surface.' },
|
|
491
491
|
|
|
492
492
|
// Minions — checklist-driven intake surfaces bound to a project
|
|
493
|
-
minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: Drafted\'s agent for people OUTSIDE the org (bidders, clients, suppliers). A visitor opens a shareable /c/<slug>-<token> link (issued only to a person in Drafted — create returns url:null to an agent; tell the user to generate the link there), the Minion guides them through a checklist, writes ONE submission record into the project, and on submit marks it
|
|
493
|
+
minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: Drafted\'s agent for people OUTSIDE the org (bidders, clients, suppliers). A visitor opens a shareable /c/<slug>-<token> link (issued only to a person in Drafted — create returns url:null to an agent; tell the user to generate the link there), the Minion guides them through a checklist, writes ONE submission record into the project, and on submit marks it in_review and queues it on output.handoff (a queue trigger) for YOUR agent to claim with trigger pending/claim/complete. A Minion never sends, shares or contacts anyone — outbound follow-up is your job. Everything in a Minion\'s config and its context pack is visible to anyone holding the link. Dispatch by `action`: meta, list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say (works even when disabled). Requires the agent allowlist.' },
|
|
494
494
|
trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (creates the trigger; its webhook URL + token are issued only to a person in Drafted — agent sessions get none, so tell the user to rotate it there), list, update (enable/disable, edit template, daily limit, executor), rotate (a person in the browser only), test (fire a synthetic delivery), deliveries (audit log), delete; for executor="queue" triggers, pending/claim/complete let a LOCAL agent poll and work queued deliveries. Requires the agent allowlist.' },
|
|
495
495
|
fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search, link, unlink, links.' },
|
|
496
496
|
// Google Drive mirror triage. Mutating (resolve/retry_all/sync rewrite frame
|
|
@@ -3371,7 +3371,7 @@ server.resource('info', 'drafted://info', {
|
|
|
3371
3371
|
};
|
|
3372
3372
|
});
|
|
3373
3373
|
|
|
3374
|
-
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses:
|
|
3374
|
+
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: backlog, todo, in_progress, in_review, done, canceled (the six are the Linear set, and the names this system used to store -- open, scheduled, needs_review, needs_decision, failed -- are still ACCEPTED and normalised) (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
|
|
3375
3375
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
3376
3376
|
path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
|
|
3377
3377
|
to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
|
|
@@ -3668,7 +3668,11 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3668
3668
|
try { await api('PATCH', `/api/project/${created.id}`, { folder: projectFolder }, orgHeader); } catch { /* folder best-effort */ }
|
|
3669
3669
|
}
|
|
3670
3670
|
projectRefCache.set(projectRef, { id: created.id, slug: created.slug, name: created.name, orgId: created.orgId, orgSlug: created.orgSlug });
|
|
3671
|
-
|
|
3671
|
+
// The folder rides the same path the listing prints (ls shows
|
|
3672
|
+
// /projects/<folder>/<slug>), so the mkdir receipt is feedable back in.
|
|
3673
|
+
// Omitting it here is what made mkdir and ls disagree on where the project is.
|
|
3674
|
+
const createdPath = [projectFolder, created.slug].filter(Boolean).join('/');
|
|
3675
|
+
return ok({ created: true, name: created.name, slug: created.slug, id: created.id, folder: projectFolder || null, path: `${folderBase}/projects/${createdPath}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${createdPath}` });
|
|
3672
3676
|
}
|
|
3673
3677
|
|
|
3674
3678
|
// ── Root: /wiki/... ────────────────────────────────────────────
|
|
@@ -4214,16 +4218,45 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4214
4218
|
const scope = rmScope(layer, lane, filename);
|
|
4215
4219
|
if (scope === 'project') {
|
|
4216
4220
|
const pid = getState().projectId;
|
|
4217
|
-
if (!pid) return err(new Error('could not resolve project id for
|
|
4218
|
-
// `to` may be a bare new name or a full destination path — the last
|
|
4219
|
-
// segment is the name either way.
|
|
4220
|
-
const newName = String(to || '').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).pop();
|
|
4221
|
-
if (!newName) return err(new Error('mv on a project path renames it — pass the new name, e.g. to="Onboarding — source of truth"'));
|
|
4222
|
-
const renamed = await api('PATCH', `/api/project/${pid}`, { name: newName }, orgHeader);
|
|
4221
|
+
if (!pid) return err(new Error('could not resolve project id for mv'));
|
|
4223
4222
|
const meta = getState().projectMeta || {};
|
|
4223
|
+
const destPath = `/o/${meta.orgSlug || orgFromPath || ''}/projects`;
|
|
4224
|
+
const raw = String(to || '').replace(/^\/+|\/+$/g, '');
|
|
4225
|
+
if (!raw) return err(new Error('mv on a project path needs a destination — a bare new display name to rename, or /o/<org>/projects/<folder>/<slug> to move it into a folder'));
|
|
4226
|
+
// A destination that ADDRESSES a project (.../projects/<folder…>/<slug>)
|
|
4227
|
+
// moves it into that folder; a bare name renames it. Both leave the slug
|
|
4228
|
+
// (and every URL) alone. Before this, the last segment was always taken
|
|
4229
|
+
// as a new display name, so a folder path renamed the project to its own
|
|
4230
|
+
// slug and left it at the org root.
|
|
4231
|
+
const segs = raw.split('/').filter(Boolean);
|
|
4232
|
+
const at = segs.lastIndexOf('projects');
|
|
4233
|
+
if (at !== -1) {
|
|
4234
|
+
const after = segs.slice(at + 1);
|
|
4235
|
+
const last = after[after.length - 1];
|
|
4236
|
+
const folder = after.slice(0, -1).join('/');
|
|
4237
|
+
if (!last || last !== meta.slug) {
|
|
4238
|
+
return err(new Error(`mv moves a project into a folder but cannot change its slug — the destination must end with the current slug "${meta.slug}" (got "${last || ''}"). Pass a bare name to rename instead.`));
|
|
4239
|
+
}
|
|
4240
|
+
if (folder === (meta.folder || '')) {
|
|
4241
|
+
return ok({
|
|
4242
|
+
moved: false, projectId: pid, folder: folder || null, name: meta.name, slug: meta.slug,
|
|
4243
|
+
path: `${destPath}/${[folder, meta.slug].filter(Boolean).join('/')}`,
|
|
4244
|
+
note: 'The project is already in that folder — nothing changed.',
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4247
|
+
const updated = await api('PATCH', `/api/project/${pid}`, { folder }, orgHeader);
|
|
4248
|
+
return ok({
|
|
4249
|
+
moved: true, projectId: pid, folder: folder || null,
|
|
4250
|
+
name: updated?.name || meta.name, slug: meta.slug,
|
|
4251
|
+
path: `${destPath}/${[folder, meta.slug].filter(Boolean).join('/')}`,
|
|
4252
|
+
slugNote: 'Slug and URLs are UNCHANGED — a folder move sets the project\u2019s folder only, so links already shared keep working.',
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
// Bare name (no /projects segment) → rename, display name only.
|
|
4256
|
+
const renamed = await api('PATCH', `/api/project/${pid}`, { name: raw }, orgHeader);
|
|
4224
4257
|
return ok({
|
|
4225
|
-
renamed: true, projectId: pid, name: renamed?.name ||
|
|
4226
|
-
path:
|
|
4258
|
+
renamed: true, projectId: pid, name: renamed?.name || raw, slug: meta.slug,
|
|
4259
|
+
path: `${destPath}/${[meta.folder, meta.slug || pid].filter(Boolean).join('/')}`,
|
|
4227
4260
|
// Say this before the agent hands a human a URL it inferred from the
|
|
4228
4261
|
// new name. Renaming is a DISPLAY-name change, exactly as in the web
|
|
4229
4262
|
// UI: the slug is left alone so every link already shared keeps working.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.7",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,13 +58,19 @@ export function stringifyExcalidrawScene(input) {
|
|
|
58
58
|
return JSON.stringify(normalizeExcalidrawScene(input), null, 2);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Element-level merge for surgical edits:
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
61
|
+
// Element-level merge for surgical edits: shallow-merge `upserts` ONTO the
|
|
62
|
+
// existing element with the same id (so a partial {id,x,y} moves it without
|
|
63
|
+
// dropping its other props), and drop every id in `removeIds`. Existing element
|
|
64
|
+
// order is preserved (z-order = array order); updated elements keep their
|
|
65
|
+
// position. This is the accumulate/merge path that write_excalidraw (full
|
|
66
|
+
// replace) is not — it lets a large scene be edited with a small payload
|
|
67
|
+
// instead of resending the whole thing.
|
|
68
|
+
//
|
|
69
|
+
// An id that does NOT exist is an ERROR, not an append. The silent-append shape
|
|
70
|
+
// shipped first and bit an agent: a typo'd/changed id added a partial
|
|
71
|
+
// `{id,strokeStyle,…}` object with no type/x/y beside the untouched original, so
|
|
72
|
+
// the render kept showing the old element and the write reported ok. Adding
|
|
73
|
+
// elements is a full-scene job (write_excalidraw); edit is for existing ones.
|
|
68
74
|
export function mergeExcalidrawElements(existing, upserts = [], removeIds = []) {
|
|
69
75
|
const scene = normalizeExcalidrawScene(existing);
|
|
70
76
|
const removeSet = new Set(Array.isArray(removeIds) ? removeIds : []);
|
|
@@ -78,7 +84,11 @@ export function mergeExcalidrawElements(existing, upserts = [], removeIds = [])
|
|
|
78
84
|
throw new Error('Each element to upsert must be an object with an id');
|
|
79
85
|
}
|
|
80
86
|
if (removeSet.has(el.id)) continue;
|
|
81
|
-
|
|
87
|
+
const prev = byId.get(el.id);
|
|
88
|
+
if (!prev) {
|
|
89
|
+
throw new Error(`No element with id "${el.id}" in this scene — element-level edit updates existing elements; write the whole scene (write_excalidraw) to add or replace one.`);
|
|
90
|
+
}
|
|
91
|
+
byId.set(el.id, { ...prev, ...el });
|
|
82
92
|
}
|
|
83
93
|
return { ...scene, elements: Array.from(byId.values()) };
|
|
84
94
|
}
|
|
@@ -18,9 +18,8 @@ assert.deepStrictEqual(r.elements.map(e => e.id), ['a', 'b', 'c'], 'order preser
|
|
|
18
18
|
assert.strictEqual(r.elements[1].x, 99, 'x updated');
|
|
19
19
|
assert.strictEqual(r.elements[1].text, 'hi', 'other props preserved on partial update');
|
|
20
20
|
|
|
21
|
-
// add
|
|
22
|
-
|
|
23
|
-
assert.deepStrictEqual(r.elements.map(e => e.id), ['a', 'b', 'c', 'd'], 'new element appended');
|
|
21
|
+
// add of an unknown id is an error, not an append (silent append shipped once)
|
|
22
|
+
assert.throws(() => mergeExcalidrawElements(base, [{ id: 'd', type: 'diamond', x: 5 }]), /No element with id/);
|
|
24
23
|
|
|
25
24
|
// remove drops by id
|
|
26
25
|
r = mergeExcalidrawElements(base, [], ['a', 'c']);
|
|
@@ -31,8 +30,8 @@ r = mergeExcalidrawElements(base, [{ id: 'a', x: 1 }], ['a']);
|
|
|
31
30
|
assert.deepStrictEqual(r.elements.map(e => e.id), ['b', 'c'], 'remove beats upsert');
|
|
32
31
|
|
|
33
32
|
// accepts a JSON string as stored content (frame.content is a string)
|
|
34
|
-
r = mergeExcalidrawElements(JSON.stringify(base), [{ id: '
|
|
35
|
-
assert.strictEqual(r.elements.
|
|
33
|
+
r = mergeExcalidrawElements(JSON.stringify(base), [{ id: 'a', x: 42 }]);
|
|
34
|
+
assert.strictEqual(r.elements[0].x, 42, 'parses stored string content and merges');
|
|
36
35
|
|
|
37
36
|
// linear elements without points are repaired (Excalidraw crashes on restore otherwise)
|
|
38
37
|
r = mergeExcalidrawElements({
|