canopycms 0.11.1 → 0.12.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/CHANGELOG.md +58 -48
- package/dist/api/branch-changes.d.ts +1 -0
- package/dist/api/branch-changes.js +1 -1
- package/dist/api/branch-entry-changes.d.ts +92 -7
- package/dist/api/branch-entry-changes.js +272 -38
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/cli/cli.js +62 -1
- package/dist/cli/generate-ai-content.js +61 -0
- package/dist/content-store.d.ts +26 -0
- package/dist/content-store.js +73 -0
- package/dist/editor/Editor.js +134 -15
- package/dist/editor/components/EntryChangesDrawer.d.ts +36 -0
- package/dist/editor/components/EntryChangesDrawer.js +141 -5
- package/dist/editor/components/EntryHeader.js +2 -4
- package/dist/editor/copy.d.ts +8 -0
- package/dist/editor/copy.js +8 -0
- package/dist/editor/entry-diff.d.ts +16 -1
- package/dist/editor/entry-diff.js +21 -11
- package/dist/editor/undo-change.d.ts +45 -0
- package/dist/editor/undo-change.js +226 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -2,19 +2,22 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { entryRefFromRepoPath, entryRefFromRepoPathShape, parseEntryParts, } from '../content-listing.js';
|
|
5
|
-
import { ContentStore, ContentStoreError } from '../content-store.js';
|
|
5
|
+
import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, } from '../content-store.js';
|
|
6
6
|
import { resolveCollectionPath } from '../content-id-index.js';
|
|
7
7
|
import { BaseBranchMissingError } from '../git-manager.js';
|
|
8
8
|
import { getBranchProtection } from '../authorization/protected-branch.js';
|
|
9
|
+
import { SchemaOps, SchemaStoreBusyError } from '../schema/schema-store.js';
|
|
10
|
+
import { canopyLogWarn } from '../utils/logger.js';
|
|
11
|
+
import { isRebaseInProgress } from '../utils/git.js';
|
|
9
12
|
import { baseBranchOf } from '../utils/base-branch.js';
|
|
10
13
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
11
14
|
import { entryAsRead } from '../utils/calendar-day.js';
|
|
12
|
-
import { isNodeError, isNotFoundError } from '../utils/error.js';
|
|
15
|
+
import { isNodeError, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
13
16
|
import { getFormatExtension } from '../utils/format.js';
|
|
14
17
|
import { entryLogicalPath } from '../paths/index.js';
|
|
15
18
|
import { defineEndpoint } from './route-builder.js';
|
|
16
19
|
import { branchNameSchema, contentIdSchema, logicalPathSchema } from './validators.js';
|
|
17
|
-
import { branchReadFailedResponse, readWhileSteady, unsteadyReadResponse, } from './branch-changes.js';
|
|
20
|
+
import { branchReadFailedResponse, readWhileSteady, SYNCING_ERROR, unsteadyReadResponse, } from './branch-changes.js';
|
|
18
21
|
/** The most of a side's file read, so one huge file can't hold the API or the browser. */
|
|
19
22
|
const MAX_SIDE_BYTES = 1024 * 1024;
|
|
20
23
|
const NOT_COMPARABLE_ERROR = "This entry can't be compared.";
|
|
@@ -110,14 +113,13 @@ const readAfter = async (store, flatSchema, target, id) => {
|
|
|
110
113
|
}
|
|
111
114
|
};
|
|
112
115
|
/**
|
|
113
|
-
* The entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when
|
|
114
|
-
* Looked for under the collection's directory first, then the whole content root
|
|
115
|
-
* directory isn't in the base tree (a collection renamed or deleted on the branch).
|
|
116
|
-
* to entries as GET /:branch/changes maps them, so the entry it lists resolves to the
|
|
117
|
-
* file here.
|
|
118
|
-
* `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
|
|
116
|
+
* The file of the entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when
|
|
117
|
+
* there is one. Looked for under the collection's directory first, then the whole content root
|
|
118
|
+
* when that directory isn't in the base tree (a collection renamed or deleted on the branch).
|
|
119
|
+
* Files map to entries as GET /:branch/changes maps them, so the entry it lists resolves to the
|
|
120
|
+
* same file here.
|
|
119
121
|
*/
|
|
120
|
-
const
|
|
122
|
+
const findBaseFile = async (git, mergeBase, logicalPath, contentId, branchContext, contentRoot) => {
|
|
121
123
|
const { flatSchema, branchRoot } = branchContext;
|
|
122
124
|
const collectionPath = logicalPath.slice(0, logicalPath.lastIndexOf('/'));
|
|
123
125
|
const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
|
|
@@ -132,35 +134,9 @@ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, con
|
|
|
132
134
|
const ref = placed.kind === 'other' ? entryRefFromRepoPathShape(file.path, contentRoot) : placed;
|
|
133
135
|
if (ref?.kind !== 'entry' || ref.logicalPath !== logicalPath)
|
|
134
136
|
continue;
|
|
135
|
-
if (
|
|
137
|
+
if (contentId !== undefined && ref.contentId !== contentId)
|
|
136
138
|
continue;
|
|
137
|
-
|
|
138
|
-
return 'too-large';
|
|
139
|
-
const entryType = entryTypeIn(flatSchema, ref.collectionPath, ref.entryType);
|
|
140
|
-
const fields = entryType?.schema ?? match.afterFields ?? [];
|
|
141
|
-
// An entry type the schema no longer has is parsed by its extension.
|
|
142
|
-
const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
|
|
143
|
-
if (!format)
|
|
144
|
-
return 'unreadable';
|
|
145
|
-
const raw = await git.showFile(mergeBase, file.path);
|
|
146
|
-
if (raw === undefined)
|
|
147
|
-
return undefined;
|
|
148
|
-
let parsed;
|
|
149
|
-
try {
|
|
150
|
-
parsed = parseEntryParts(raw, format);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
return 'unreadable';
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
format,
|
|
157
|
-
entryType: ref.entryType,
|
|
158
|
-
data: entryAsRead(fields, parsed.data),
|
|
159
|
-
...(parsed.body !== undefined && {
|
|
160
|
-
body: parsed.body,
|
|
161
|
-
bodyFieldName: findBodyFieldName(fields),
|
|
162
|
-
}),
|
|
163
|
-
};
|
|
139
|
+
return { path: file.path, size: file.size, ref, placed: placed.kind === 'entry' };
|
|
164
140
|
}
|
|
165
141
|
// The collection's directory is in the base tree, so an entry not in it is new.
|
|
166
142
|
if (files.length > 0)
|
|
@@ -168,6 +144,44 @@ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, con
|
|
|
168
144
|
}
|
|
169
145
|
return undefined;
|
|
170
146
|
};
|
|
147
|
+
/**
|
|
148
|
+
* The entry at `logicalPath` in `mergeBase`'s tree (`findBaseFile`), read as the editor reads
|
|
149
|
+
* one. A base file whose collection or entry type the schema no longer has is keyed with
|
|
150
|
+
* `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
|
|
151
|
+
*/
|
|
152
|
+
const readBefore = async (git, mergeBase, logicalPath, match, branchContext, contentRoot) => {
|
|
153
|
+
const file = await findBaseFile(git, mergeBase, logicalPath, match.contentId, branchContext, contentRoot);
|
|
154
|
+
if (!file)
|
|
155
|
+
return undefined;
|
|
156
|
+
const { ref } = file;
|
|
157
|
+
if (file.size > MAX_SIDE_BYTES)
|
|
158
|
+
return 'too-large';
|
|
159
|
+
const entryType = entryTypeIn(branchContext.flatSchema, ref.collectionPath, ref.entryType);
|
|
160
|
+
const fields = entryType?.schema ?? match.afterFields ?? [];
|
|
161
|
+
// An entry type the schema no longer has is parsed by its extension.
|
|
162
|
+
const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
|
|
163
|
+
if (!format)
|
|
164
|
+
return 'unreadable';
|
|
165
|
+
const raw = await git.showFile(mergeBase, file.path);
|
|
166
|
+
if (raw === undefined)
|
|
167
|
+
return undefined;
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = parseEntryParts(raw, format);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return 'unreadable';
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
format,
|
|
177
|
+
entryType: ref.entryType,
|
|
178
|
+
data: entryAsRead(fields, parsed.data),
|
|
179
|
+
...(parsed.body !== undefined && {
|
|
180
|
+
body: parsed.body,
|
|
181
|
+
bodyFieldName: findBodyFieldName(fields),
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
};
|
|
171
185
|
const entryChangeHandler = async (gc, ctx, req, params) => {
|
|
172
186
|
const { branchContext } = gc;
|
|
173
187
|
const { config } = ctx.services;
|
|
@@ -255,6 +269,226 @@ export const readBranchEntryChange = defineEndpoint({
|
|
|
255
269
|
guards: ['branchAccessWithSchema'],
|
|
256
270
|
handler: entryChangeHandler,
|
|
257
271
|
});
|
|
272
|
+
const restoreParamsSchema = z.object({
|
|
273
|
+
branch: branchNameSchema,
|
|
274
|
+
entryPath: logicalPathSchema,
|
|
275
|
+
});
|
|
276
|
+
const restoreBodySchema = z.object({
|
|
277
|
+
/** The deleted entry's content id, which the base file must carry and the restore keeps. */
|
|
278
|
+
id: contentIdSchema,
|
|
279
|
+
});
|
|
280
|
+
const NOT_RESTORABLE_ERROR = "This entry isn't on the base branch, so it can't be restored.";
|
|
281
|
+
const TYPE_CHANGED_ERROR = "This entry's type or format has changed since it was deleted, so it can't be restored.";
|
|
282
|
+
const TOO_LARGE_TO_RESTORE_ERROR = 'This entry is too large to restore here.';
|
|
283
|
+
const UNREADABLE_TO_RESTORE_ERROR = "This entry's saved file doesn't read, so it can't be restored.";
|
|
284
|
+
/** `order` with `id` put back after its nearest predecessor in `baseOrder` that `order` holds. */
|
|
285
|
+
const orderWith = (order, id, baseOrder) => {
|
|
286
|
+
const at = baseOrder.indexOf(id);
|
|
287
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
288
|
+
const index = order.indexOf(baseOrder[i]);
|
|
289
|
+
if (index >= 0)
|
|
290
|
+
return [...order.slice(0, index + 1), id, ...order.slice(index + 1)];
|
|
291
|
+
}
|
|
292
|
+
return [id, ...order];
|
|
293
|
+
};
|
|
294
|
+
/** A collection's `order` in its `.collection.json` at `rev`, or undefined when it has none. */
|
|
295
|
+
const orderAt = async (git, rev, collectionRepoDir) => {
|
|
296
|
+
const raw = await git.showFile(rev, `${collectionRepoDir}/.collection.json`);
|
|
297
|
+
if (raw === undefined)
|
|
298
|
+
return undefined;
|
|
299
|
+
try {
|
|
300
|
+
const order = JSON.parse(raw)?.order;
|
|
301
|
+
return Array.isArray(order) && order.every((id) => typeof id === 'string') ? order : undefined;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
/** Git failing in a restore's pre-write HEAD check, answered as a failed read is. */
|
|
308
|
+
class HeadReadError extends Error {
|
|
309
|
+
constructor(failure) {
|
|
310
|
+
super("Couldn't read the branch's HEAD");
|
|
311
|
+
this.failure = failure;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const restoreEntryHandler = async (gc, ctx, req, params, body) => {
|
|
315
|
+
const { branchContext } = gc;
|
|
316
|
+
const { branchRoot } = branchContext;
|
|
317
|
+
const { config } = ctx.services;
|
|
318
|
+
const base = branchContext.branch.baseBranch ?? baseBranchOf(config);
|
|
319
|
+
const contentRoot = config.contentRoot || 'content';
|
|
320
|
+
const store = new ContentStore(branchRoot, branchContext.flatSchema, {
|
|
321
|
+
contentRootName: contentRoot,
|
|
322
|
+
});
|
|
323
|
+
const target = canonicalPath(store, underContentRoot(params.entryPath, contentRoot));
|
|
324
|
+
const notRestorable = {
|
|
325
|
+
ok: false,
|
|
326
|
+
status: 404,
|
|
327
|
+
error: NOT_RESTORABLE_ERROR,
|
|
328
|
+
};
|
|
329
|
+
if (!target.stored)
|
|
330
|
+
return notRestorable;
|
|
331
|
+
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchRoot, req.user);
|
|
332
|
+
if (!checkAccess(target.logicalPath, 'edit').allowed) {
|
|
333
|
+
return { ok: false, status: 403, error: 'Forbidden' };
|
|
334
|
+
}
|
|
335
|
+
const { collectionPath, slug } = target.stored;
|
|
336
|
+
const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
|
|
337
|
+
const collectionRepoDir = collectionDir && path.relative(branchRoot, collectionDir).split(path.sep).join('/');
|
|
338
|
+
const git = ctx.services.createGitManagerFor(branchRoot, {
|
|
339
|
+
baseBranch: branchContext.branch.baseBranch,
|
|
340
|
+
});
|
|
341
|
+
let read;
|
|
342
|
+
try {
|
|
343
|
+
read = await readWhileSteady(branchRoot, git, async () => {
|
|
344
|
+
const mergeBase = await git.mergeBaseWithBase();
|
|
345
|
+
if (!mergeBase)
|
|
346
|
+
return undefined;
|
|
347
|
+
const file = await findBaseFile(git, mergeBase, target.logicalPath, body.id, branchContext, contentRoot);
|
|
348
|
+
if (!file)
|
|
349
|
+
return undefined;
|
|
350
|
+
// Only a file the current schema places as this entry type, by name and extension, goes
|
|
351
|
+
// back: another format's bytes under this type's extension would not read.
|
|
352
|
+
if (!file.placed)
|
|
353
|
+
return 'type-changed';
|
|
354
|
+
if (file.size > MAX_SIDE_BYTES)
|
|
355
|
+
return 'too-large';
|
|
356
|
+
const raw = await git.showFile(mergeBase, file.path);
|
|
357
|
+
if (raw === undefined)
|
|
358
|
+
return undefined;
|
|
359
|
+
const baseOrder = collectionRepoDir
|
|
360
|
+
? await orderAt(git, mergeBase, collectionRepoDir)
|
|
361
|
+
: undefined;
|
|
362
|
+
return {
|
|
363
|
+
raw,
|
|
364
|
+
entryType: file.ref.entryType,
|
|
365
|
+
head: await git.headSha(),
|
|
366
|
+
...(baseOrder && { baseOrder }),
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
if (err instanceof BaseBranchMissingError)
|
|
372
|
+
return notRestorable;
|
|
373
|
+
return branchReadFailedResponse(branchContext.branch.name, base, err);
|
|
374
|
+
}
|
|
375
|
+
if (!read.steady)
|
|
376
|
+
return unsteadyReadResponse(read.reason, base);
|
|
377
|
+
const entry = read.value;
|
|
378
|
+
if (entry === 'too-large')
|
|
379
|
+
return { ok: false, status: 413, error: TOO_LARGE_TO_RESTORE_ERROR };
|
|
380
|
+
if (entry === 'type-changed')
|
|
381
|
+
return { ok: false, status: 409, error: TYPE_CHANGED_ERROR };
|
|
382
|
+
if (!entry)
|
|
383
|
+
return notRestorable;
|
|
384
|
+
const entryType = entryTypeIn(branchContext.flatSchema, collectionPath, entry.entryType);
|
|
385
|
+
if (!entryType)
|
|
386
|
+
return { ok: false, status: 409, error: TYPE_CHANGED_ERROR };
|
|
387
|
+
try {
|
|
388
|
+
parseEntryParts(entry.raw, entryType.format);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return { ok: false, status: 422, error: UNREADABLE_TO_RESTORE_ERROR };
|
|
392
|
+
}
|
|
393
|
+
// As a create checks it (SCH-H3, api/content.ts), best-effort: count-then-write isn't atomic.
|
|
394
|
+
const { maxItems } = entryType;
|
|
395
|
+
if (maxItems !== undefined &&
|
|
396
|
+
(await store.countEntriesOfType(collectionPath, entry.entryType)) >= maxItems) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
status: 422,
|
|
400
|
+
error: `Cannot restore entry: type "${entry.entryType}" allows at most ${maxItems} ${maxItems === 1 ? 'entry' : 'entries'}`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
await store.restore(collectionPath, slug, { id: body.id, entryTypeName: entry.entryType, raw: entry.raw }, {
|
|
405
|
+
// The bytes are the merge base's as HEAD stood at the read; a sync since may have
|
|
406
|
+
// updated the entry upstream, so a moved HEAD refuses rather than reverting that.
|
|
407
|
+
beforeWrite: async () => {
|
|
408
|
+
let moved;
|
|
409
|
+
try {
|
|
410
|
+
moved = (await isRebaseInProgress(branchRoot)) || (await git.headSha()) !== entry.head;
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
throw new HeadReadError(err);
|
|
414
|
+
}
|
|
415
|
+
if (moved)
|
|
416
|
+
throw new BranchSyncingError(SYNCING_ERROR(base));
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (err instanceof HeadReadError) {
|
|
422
|
+
return branchReadFailedResponse(branchContext.branch.name, base, err.failure);
|
|
423
|
+
}
|
|
424
|
+
if (err instanceof BranchSyncingError) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
status: 409,
|
|
428
|
+
error: err.message,
|
|
429
|
+
code: err.outcome === 'unknown' ? 'WRITE_OUTCOME_UNKNOWN' : 'BRANCH_SYNCING',
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// A refusal the editor can act on: the slug or the id is taken, or a URL is contested.
|
|
433
|
+
if (err instanceof ContentConflictError)
|
|
434
|
+
return { ok: false, status: 409, error: err.message };
|
|
435
|
+
if (err instanceof ContentStoreError) {
|
|
436
|
+
return { ok: false, status: 400, error: sanitizeErrorMessage(err.message) };
|
|
437
|
+
}
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
const warning = await restoreOrder(ctx, branchContext, collectionPath, body.id, entry.baseOrder);
|
|
441
|
+
return {
|
|
442
|
+
ok: true,
|
|
443
|
+
status: 200,
|
|
444
|
+
data: { logicalPath: target.logicalPath, contentId: body.id, ...(warning && { warning }) },
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
/**
|
|
448
|
+
* Puts a restored entry's id back in its collection's manual order, after its nearest neighbour
|
|
449
|
+
* at the base, undoing what a delete took out (api/entries.ts). Best-effort: the entry is back
|
|
450
|
+
* either way, so a failure is a warning. Runs after the restore, whose content-write lock a
|
|
451
|
+
* schema write also takes.
|
|
452
|
+
*/
|
|
453
|
+
const restoreOrder = async (ctx, branchContext, collectionPath, id, baseOrder) => {
|
|
454
|
+
const collection = branchContext.flatSchema.find((item) => item.type === 'collection' && item.logicalPath === collectionPath);
|
|
455
|
+
const order = collection?.order;
|
|
456
|
+
if (!order || !baseOrder?.includes(id) || order.includes(id))
|
|
457
|
+
return undefined;
|
|
458
|
+
const schemaStore = new SchemaOps(path.join(branchContext.branchRoot, ctx.services.config.contentRoot || 'content'), ctx.services.entrySchemaRegistry, ctx.services, branchContext.branchRoot);
|
|
459
|
+
try {
|
|
460
|
+
await schemaStore.updateOrder(collectionPath, orderWith(order, id, baseOrder));
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
// An `'unknown'` outcome means the update ran to completion.
|
|
465
|
+
if (err instanceof SchemaStoreBusyError && err.outcome === 'unknown')
|
|
466
|
+
return undefined;
|
|
467
|
+
canopyLogWarn(`CanopyCMS: restored ${collectionPath}/${id} but not its place in the order:`, err);
|
|
468
|
+
return 'Entry restored, but its place in the collection order couldn’t be put back, so it is listed last.';
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* Puts back an entry deleted on the branch, as the commit the branch forked from holds it, with
|
|
473
|
+
* its content id (`ContentStore.restore`).
|
|
474
|
+
* POST /:branch/restore-entry/...entryPath
|
|
475
|
+
* @internal Exported for tests.
|
|
476
|
+
*/
|
|
477
|
+
export const restoreBranchEntry = defineEndpoint({
|
|
478
|
+
namespace: 'changes',
|
|
479
|
+
name: 'restore',
|
|
480
|
+
method: 'POST',
|
|
481
|
+
path: '/:branch/restore-entry/...entryPath',
|
|
482
|
+
params: restoreParamsSchema,
|
|
483
|
+
body: restoreBodySchema,
|
|
484
|
+
bodyType: 'RestoreEntryBody',
|
|
485
|
+
responseType: 'RestoreEntryResponse',
|
|
486
|
+
response: {},
|
|
487
|
+
defaultMockData: { logicalPath: '', contentId: '' },
|
|
488
|
+
guards: ['schema', 'writableBranch'],
|
|
489
|
+
handler: restoreEntryHandler,
|
|
490
|
+
});
|
|
258
491
|
export const BRANCH_ENTRY_CHANGE_ROUTES = {
|
|
259
492
|
entry: readBranchEntryChange,
|
|
493
|
+
restore: restoreBranchEntry,
|
|
260
494
|
};
|
package/dist/api/client.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { isApiTimeoutError, WRITE_TIMED_OUT_MESSAGE, type RequestOptions } from
|
|
|
10
10
|
import type { OperatingMode } from '../operating-mode/types.js';
|
|
11
11
|
import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
|
|
12
12
|
import type { BranchChangesResponse } from './branch-changes.js';
|
|
13
|
-
import type { BranchEntryChangeResponse } from './branch-entry-changes.js';
|
|
13
|
+
import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
|
|
14
14
|
import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
|
|
15
15
|
import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
|
|
16
16
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
@@ -92,6 +92,8 @@ export declare class CanopyApiClient {
|
|
|
92
92
|
list: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchChangesResponse>;
|
|
93
93
|
/** GET /:branch/changes/...entryPath */
|
|
94
94
|
entry: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchEntryChangeResponse>;
|
|
95
|
+
/** POST /:branch/restore-entry/...entryPath */
|
|
96
|
+
restore: (params: Record<string, string>, body: RestoreEntryBody, options?: RequestOptions) => Promise<RestoreEntryResponse>;
|
|
95
97
|
};
|
|
96
98
|
readonly workflow: {
|
|
97
99
|
/** POST /:branch/withdraw */
|
package/dist/api/client.js
CHANGED
|
@@ -50,6 +50,10 @@ export class CanopyApiClient {
|
|
|
50
50
|
entry: (params, options) => {
|
|
51
51
|
return this.request('GET', this.buildPath('/:branch/changes/...entryPath', params), undefined, 'standard', options);
|
|
52
52
|
},
|
|
53
|
+
/** POST /:branch/restore-entry/...entryPath */
|
|
54
|
+
restore: (params, body, options) => {
|
|
55
|
+
return this.request('POST', this.buildPath('/:branch/restore-entry/...entryPath', params), body, 'standard', options);
|
|
56
|
+
},
|
|
53
57
|
};
|
|
54
58
|
this.workflow = {
|
|
55
59
|
/** POST /:branch/withdraw */
|
package/dist/cli/cli.js
CHANGED
|
@@ -8454,7 +8454,7 @@ async function generateAIContentCLI(options) {
|
|
|
8454
8454
|
console.log(` Output: ${result.outputDir}
|
|
8455
8455
|
`);
|
|
8456
8456
|
}
|
|
8457
|
-
var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, DAY, MS_PER_DAY, isRealDay, dayParts, isCalendarDay, dayOfBareDate, mapItems, blockItemWithDays, valueWithDays, entryAsRead, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, AI_USER_ID, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
|
|
8457
|
+
var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, DAY, MS_PER_DAY, isRealDay, dayParts, isCalendarDay, dayOfBareDate, mapItems, blockItemWithDays, valueWithDays, entryAsRead, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, RESTORE_SLUG_TAKEN_MESSAGE, RESTORE_ID_TAKEN_MESSAGE, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, AI_USER_ID, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
|
|
8458
8458
|
var init_generate_ai_content = __esm({
|
|
8459
8459
|
"dist/cli/generate-ai-content.js"() {
|
|
8460
8460
|
"use strict";
|
|
@@ -11170,6 +11170,8 @@ var init_generate_ai_content = __esm({
|
|
|
11170
11170
|
this.code = code;
|
|
11171
11171
|
}
|
|
11172
11172
|
};
|
|
11173
|
+
RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
|
|
11174
|
+
RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
|
|
11173
11175
|
ContentConflictError = class extends Error {
|
|
11174
11176
|
constructor(message = "Content was modified by another editor") {
|
|
11175
11177
|
super(message);
|
|
@@ -12014,6 +12016,65 @@ var init_generate_ai_content = __esm({
|
|
|
12014
12016
|
throw new ContentConflictError();
|
|
12015
12017
|
});
|
|
12016
12018
|
}
|
|
12019
|
+
/**
|
|
12020
|
+
* Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
|
|
12021
|
+
* under its old slug, entry type and content ID, so references and comment threads find it
|
|
12022
|
+
* again and the branch no longer differs from the base there.
|
|
12023
|
+
*
|
|
12024
|
+
* A create under write()'s locks and create-only guards that never replaces or relocates
|
|
12025
|
+
* anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
|
|
12026
|
+
* or the target directory holds the ID: unlike write(), it has no index repair, so an ID
|
|
12027
|
+
* found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
|
|
12028
|
+
* warmed outside the lock, as write() warms it, and rescanned under the lock only when the
|
|
12029
|
+
* generation marker says another host changed the tree.
|
|
12030
|
+
*/
|
|
12031
|
+
async restore(collectionPath, slug, entry, options = {}) {
|
|
12032
|
+
await this.idIndex();
|
|
12033
|
+
const collection = this.assertCollection(collectionPath);
|
|
12034
|
+
if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
|
|
12035
|
+
throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
|
|
12036
|
+
}
|
|
12037
|
+
this.assertEntryTypeAvailable(collection, entry.entryTypeName);
|
|
12038
|
+
return this.withContentWriteExclusion(async () => {
|
|
12039
|
+
await options.beforeWrite?.();
|
|
12040
|
+
if (await readContentIndexGeneration(this.root) !== this.loadedDiskGeneration) {
|
|
12041
|
+
this.invalidateIndex();
|
|
12042
|
+
}
|
|
12043
|
+
await this.idIndex();
|
|
12044
|
+
return withLock(this.createLockKey(collection, slug), async () => {
|
|
12045
|
+
const target = await this.buildPaths(collection, slug, {
|
|
12046
|
+
existingId: entry.id,
|
|
12047
|
+
entryTypeName: entry.entryTypeName
|
|
12048
|
+
});
|
|
12049
|
+
const dir = path112.dirname(target.absolutePath);
|
|
12050
|
+
const liveIndex = this._idIndex;
|
|
12051
|
+
if (liveIndex.findById(entry.id) || liveIndex.getDuplicateFor(entry.id) || (await this.findEntryPathsById(dir, entry.id)).length > 0) {
|
|
12052
|
+
throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
|
|
12053
|
+
}
|
|
12054
|
+
if ((await this.buildPaths(collection, slug)).existed) {
|
|
12055
|
+
throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
|
|
12056
|
+
}
|
|
12057
|
+
const routable = parseSlug(target.slug);
|
|
12058
|
+
if (!routable.ok) {
|
|
12059
|
+
throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, "VALIDATION");
|
|
12060
|
+
}
|
|
12061
|
+
await this.assertUrlPathAvailable(dir, target.slug);
|
|
12062
|
+
await fs9.mkdir(dir, { recursive: true });
|
|
12063
|
+
await atomicWriteFile2(target.absolutePath, entry.raw);
|
|
12064
|
+
const index = this._idIndex;
|
|
12065
|
+
if (!index.findById(entry.id)) {
|
|
12066
|
+
index.add({
|
|
12067
|
+
type: "entry",
|
|
12068
|
+
relativePath: target.relativePath,
|
|
12069
|
+
collection: collectionPath,
|
|
12070
|
+
slug
|
|
12071
|
+
});
|
|
12072
|
+
}
|
|
12073
|
+
await this.recordOwnMutation(index);
|
|
12074
|
+
return { relativePath: target.relativePath };
|
|
12075
|
+
});
|
|
12076
|
+
});
|
|
12077
|
+
}
|
|
12017
12078
|
/**
|
|
12018
12079
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
12019
12080
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
|
@@ -5325,6 +5325,8 @@ var ContentStoreError = class extends Error {
|
|
|
5325
5325
|
this.code = code;
|
|
5326
5326
|
}
|
|
5327
5327
|
};
|
|
5328
|
+
var RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
|
|
5329
|
+
var RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
|
|
5328
5330
|
var ContentConflictError = class extends Error {
|
|
5329
5331
|
constructor(message = "Content was modified by another editor") {
|
|
5330
5332
|
super(message);
|
|
@@ -6218,6 +6220,65 @@ var ContentStore = class {
|
|
|
6218
6220
|
throw new ContentConflictError();
|
|
6219
6221
|
});
|
|
6220
6222
|
}
|
|
6223
|
+
/**
|
|
6224
|
+
* Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
|
|
6225
|
+
* under its old slug, entry type and content ID, so references and comment threads find it
|
|
6226
|
+
* again and the branch no longer differs from the base there.
|
|
6227
|
+
*
|
|
6228
|
+
* A create under write()'s locks and create-only guards that never replaces or relocates
|
|
6229
|
+
* anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
|
|
6230
|
+
* or the target directory holds the ID: unlike write(), it has no index repair, so an ID
|
|
6231
|
+
* found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
|
|
6232
|
+
* warmed outside the lock, as write() warms it, and rescanned under the lock only when the
|
|
6233
|
+
* generation marker says another host changed the tree.
|
|
6234
|
+
*/
|
|
6235
|
+
async restore(collectionPath, slug, entry, options = {}) {
|
|
6236
|
+
await this.idIndex();
|
|
6237
|
+
const collection = this.assertCollection(collectionPath);
|
|
6238
|
+
if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
|
|
6239
|
+
throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
|
|
6240
|
+
}
|
|
6241
|
+
this.assertEntryTypeAvailable(collection, entry.entryTypeName);
|
|
6242
|
+
return this.withContentWriteExclusion(async () => {
|
|
6243
|
+
await options.beforeWrite?.();
|
|
6244
|
+
if (await readContentIndexGeneration(this.root) !== this.loadedDiskGeneration) {
|
|
6245
|
+
this.invalidateIndex();
|
|
6246
|
+
}
|
|
6247
|
+
await this.idIndex();
|
|
6248
|
+
return withLock(this.createLockKey(collection, slug), async () => {
|
|
6249
|
+
const target = await this.buildPaths(collection, slug, {
|
|
6250
|
+
existingId: entry.id,
|
|
6251
|
+
entryTypeName: entry.entryTypeName
|
|
6252
|
+
});
|
|
6253
|
+
const dir = path11.dirname(target.absolutePath);
|
|
6254
|
+
const liveIndex = this._idIndex;
|
|
6255
|
+
if (liveIndex.findById(entry.id) || liveIndex.getDuplicateFor(entry.id) || (await this.findEntryPathsById(dir, entry.id)).length > 0) {
|
|
6256
|
+
throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
|
|
6257
|
+
}
|
|
6258
|
+
if ((await this.buildPaths(collection, slug)).existed) {
|
|
6259
|
+
throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
|
|
6260
|
+
}
|
|
6261
|
+
const routable = parseSlug(target.slug);
|
|
6262
|
+
if (!routable.ok) {
|
|
6263
|
+
throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, "VALIDATION");
|
|
6264
|
+
}
|
|
6265
|
+
await this.assertUrlPathAvailable(dir, target.slug);
|
|
6266
|
+
await fs9.mkdir(dir, { recursive: true });
|
|
6267
|
+
await atomicWriteFile(target.absolutePath, entry.raw);
|
|
6268
|
+
const index = this._idIndex;
|
|
6269
|
+
if (!index.findById(entry.id)) {
|
|
6270
|
+
index.add({
|
|
6271
|
+
type: "entry",
|
|
6272
|
+
relativePath: target.relativePath,
|
|
6273
|
+
collection: collectionPath,
|
|
6274
|
+
slug
|
|
6275
|
+
});
|
|
6276
|
+
}
|
|
6277
|
+
await this.recordOwnMutation(index);
|
|
6278
|
+
return { relativePath: target.relativePath };
|
|
6279
|
+
});
|
|
6280
|
+
});
|
|
6281
|
+
}
|
|
6221
6282
|
/**
|
|
6222
6283
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
6223
6284
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
package/dist/content-store.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export declare class ContentStoreError extends Error {
|
|
|
43
43
|
code: ContentStoreErrorCode;
|
|
44
44
|
constructor(message: string, code: ContentStoreErrorCode);
|
|
45
45
|
}
|
|
46
|
+
/** Why a restore refuses: another entry took the slug after this one was deleted. */
|
|
47
|
+
export declare const RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
|
|
48
|
+
/** Why a restore refuses: the entry, by its content ID, is on the branch already. */
|
|
49
|
+
export declare const RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
|
|
46
50
|
/**
|
|
47
51
|
* Thrown when `expectedVersion` on a write doesn't match the file's current mtime.
|
|
48
52
|
* Indicates a cross-process concurrent write — the caller should reload and retry.
|
|
@@ -480,6 +484,28 @@ export declare class ContentStore {
|
|
|
480
484
|
protected afterPrePassForTesting(): Promise<void>;
|
|
481
485
|
/** Delete an entry and remove it from the index. */
|
|
482
486
|
delete(collectionPath: LogicalPath, slug: Slug): Promise<void>;
|
|
487
|
+
/**
|
|
488
|
+
* Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
|
|
489
|
+
* under its old slug, entry type and content ID, so references and comment threads find it
|
|
490
|
+
* again and the branch no longer differs from the base there.
|
|
491
|
+
*
|
|
492
|
+
* A create under write()'s locks and create-only guards that never replaces or relocates
|
|
493
|
+
* anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
|
|
494
|
+
* or the target directory holds the ID: unlike write(), it has no index repair, so an ID
|
|
495
|
+
* found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
|
|
496
|
+
* warmed outside the lock, as write() warms it, and rescanned under the lock only when the
|
|
497
|
+
* generation marker says another host changed the tree.
|
|
498
|
+
*/
|
|
499
|
+
restore(collectionPath: LogicalPath, slug: Slug, entry: {
|
|
500
|
+
id: ContentId;
|
|
501
|
+
entryTypeName: string;
|
|
502
|
+
raw: string;
|
|
503
|
+
}, options?: {
|
|
504
|
+
/** Runs under the content-write lock before anything is written; throwing refuses. */
|
|
505
|
+
beforeWrite?: () => Promise<void>;
|
|
506
|
+
}): Promise<{
|
|
507
|
+
relativePath: PhysicalPath;
|
|
508
|
+
}>;
|
|
483
509
|
/**
|
|
484
510
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
485
511
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|