canopycms 0.12.0 → 0.13.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 +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { branchParamSchema } from './validators.js';
|
|
2
|
+
import { toBranchListItem } from './branch.js';
|
|
3
|
+
import { defineEndpoint } from './route-builder.js';
|
|
4
|
+
import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
|
|
5
|
+
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
6
|
+
import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
|
|
7
|
+
import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
|
|
8
|
+
import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
9
|
+
import { BASE_UPDATE_GIVE_UP_MS, BASE_UPDATE_REQUEST_INTERVAL_MS, SYNC_BRANCH_ACTION, isBaseUpdatePending, } from '../base-update.js';
|
|
10
|
+
import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
11
|
+
import { getErrorMessage, redactCredentials } from '../utils/error.js';
|
|
12
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
13
|
+
/**
|
|
14
|
+
* Ask the worker to bring the base branch into this one now, rather than at its next sync
|
|
15
|
+
* (base-update.ts). Saved work is committed first, locally: the sync skips a branch with
|
|
16
|
+
* uncommitted changes, so without the commit the request would do nothing.
|
|
17
|
+
*
|
|
18
|
+
* The request is recorded on branch.json, which is what the worker answers, so a lost task or a
|
|
19
|
+
* worker restart costs only the wait for its next regular cycle. The task only wakes it early.
|
|
20
|
+
* Where the mode runs no worker loop (operating-mode `baseUpdateRunsInRequest`), a new request runs
|
|
21
|
+
* the worker's merge itself and is answered before the response; one it joins is answered by the
|
|
22
|
+
* run that made it.
|
|
23
|
+
* A request joins the last one, answering with its time: while that is unanswered, under
|
|
24
|
+
* BASE_UPDATE_GIVE_UP_MS old and either under BASE_UPDATE_REQUEST_INTERVAL_MS old or still queued;
|
|
25
|
+
* or once it was answered under BASE_UPDATE_REQUEST_INTERVAL_MS ago without failing. Anything
|
|
26
|
+
* else, a Retry after a failure among them, is a new request.
|
|
27
|
+
*/
|
|
28
|
+
const updateFromBaseHandler = async (gc, ctx, req, _params) => {
|
|
29
|
+
const { branchContext } = gc;
|
|
30
|
+
const { config } = ctx.services;
|
|
31
|
+
const { branch } = branchContext;
|
|
32
|
+
const runsInRequest = operatingStrategy(config.mode).baseUpdateRunsInRequest();
|
|
33
|
+
if (!clientOperatingStrategy(config.mode).syncsBranchesWithBase()) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
status: 409,
|
|
37
|
+
error: `Getting the latest changes needs the CMS worker, which ${config.mode} mode doesn't run.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const { isProtected } = getBranchProtection(config, branch.name, branch.baseBranch);
|
|
41
|
+
const allowed = canPerformWorkflowAction(branchContext, req.user, config.defaultBranchAccess ?? 'deny', { isProtectedBranch: isProtected });
|
|
42
|
+
if (!allowed) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
status: 403,
|
|
46
|
+
error: 'You do not have permission to get the latest changes for this branch.',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (isProtected) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
status: 409,
|
|
53
|
+
error: 'This branch is the one changes come from, so it has no latest changes to get.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Only an editing branch takes the base in; the worker leaves the others as they are.
|
|
57
|
+
if (branch.status !== 'editing') {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
status: 409,
|
|
61
|
+
error: branch.status === 'submitted' || branch.status === 'approved'
|
|
62
|
+
? 'This branch is in review. Withdraw it from review to get the latest changes.'
|
|
63
|
+
: "This branch can't be edited, so it can't get the latest changes.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const base = branch.baseBranch ?? baseBranchOf(config);
|
|
67
|
+
const { authPlugin } = ctx;
|
|
68
|
+
try {
|
|
69
|
+
await ctx.services.commitSavedWork({
|
|
70
|
+
context: branchContext,
|
|
71
|
+
message: `Save changes before getting the latest from ${base}`,
|
|
72
|
+
lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err instanceof ContentWriteLockBusyError) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
status: 409,
|
|
80
|
+
error: err.outcome === 'not-run'
|
|
81
|
+
? 'This branch is being synced, or a save is in progress. Nothing changed; try again in a moment.'
|
|
82
|
+
: "This branch was being synced while your saved work was prepared, so the latest changes weren't requested. Try again.",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
console.error(`CanopyCMS: Could not commit the saved changes of ${branch.name} before getting the latest:`, redactCredentials(getErrorMessage(err)));
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
status: 500,
|
|
89
|
+
error: "Your saved changes couldn't be prepared, so the latest changes weren't requested.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const taskDir = getTaskQueueDir(config);
|
|
93
|
+
const queued = !runsInRequest &&
|
|
94
|
+
(await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER)).some((t) => t.action === SYNC_BRANCH_ACTION && t.payload.branch === branch.name);
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
let requestedAt = new Date(now).toISOString();
|
|
97
|
+
let joined = false;
|
|
98
|
+
let leftEditing;
|
|
99
|
+
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
100
|
+
const saved = await meta.update((existing) => {
|
|
101
|
+
if (existing?.branch.name !== branch.name)
|
|
102
|
+
return null;
|
|
103
|
+
// A submit since the guard read: the worker would leave the request standing until a withdraw.
|
|
104
|
+
if (existing.branch.status !== 'editing') {
|
|
105
|
+
leftEditing = existing.branch.status;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const last = existing.branch.baseUpdateRequestedAt;
|
|
109
|
+
const age = last === undefined ? Infinity : now - Date.parse(last);
|
|
110
|
+
const answer = existing.branch.lastBaseUpdate;
|
|
111
|
+
const pending = isBaseUpdatePending(existing.branch);
|
|
112
|
+
// An unanswered request is joined until the editor stops waiting on it. An answer under the
|
|
113
|
+
// interval old stands for a new click too, except a failure, whose Retry must ask again, and
|
|
114
|
+
// except where the request runs the merge itself, which costs no worker cycle.
|
|
115
|
+
const joinsPending = pending && age < BASE_UPDATE_GIVE_UP_MS && (queued || age < BASE_UPDATE_REQUEST_INTERVAL_MS);
|
|
116
|
+
const answeredRecently = !runsInRequest &&
|
|
117
|
+
!pending &&
|
|
118
|
+
answer !== undefined &&
|
|
119
|
+
answer.outcome !== 'failed' &&
|
|
120
|
+
now - Date.parse(answer.at) < BASE_UPDATE_REQUEST_INTERVAL_MS;
|
|
121
|
+
if (last !== undefined && (joinsPending || answeredRecently)) {
|
|
122
|
+
requestedAt = last;
|
|
123
|
+
joined = true;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return { branch: { baseUpdateRequestedAt: requestedAt } };
|
|
127
|
+
}, { keepUpdatedAt: true });
|
|
128
|
+
if (leftEditing) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
status: 409,
|
|
132
|
+
error: leftEditing === 'submitted' || leftEditing === 'approved'
|
|
133
|
+
? 'This branch is in review. Withdraw it from review to get the latest changes.'
|
|
134
|
+
: "This branch can't be edited, so it can't get the latest changes.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
let current = saved ?? (await BranchMetadataFileManager.loadOnly(branchContext.branchRoot));
|
|
138
|
+
if (current?.branch.name !== branch.name) {
|
|
139
|
+
return { ok: false, status: 404, error: 'This branch no longer exists.' };
|
|
140
|
+
}
|
|
141
|
+
// A joined request is answered by the run that made it.
|
|
142
|
+
if (runsInRequest && !joined) {
|
|
143
|
+
// Answered here where the run couldn't, as nothing else would: an open request holds the form
|
|
144
|
+
// read-only.
|
|
145
|
+
const answerFailed = () => meta
|
|
146
|
+
.update((existing) => existing?.branch.name === branch.name &&
|
|
147
|
+
existing.branch.baseUpdateRequestedAt === requestedAt &&
|
|
148
|
+
isBaseUpdatePending(existing.branch)
|
|
149
|
+
? { branch: { lastBaseUpdate: { at: requestedAt, outcome: 'failed' } } }
|
|
150
|
+
: null, { keepUpdatedAt: true })
|
|
151
|
+
.catch(() => undefined);
|
|
152
|
+
let run;
|
|
153
|
+
try {
|
|
154
|
+
run = await ctx.services.runBaseUpdateNow(branchContext);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error(`CanopyCMS: Could not bring the latest changes into ${branch.name}:`, redactCredentials(getErrorMessage(err)));
|
|
158
|
+
await answerFailed();
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 500,
|
|
162
|
+
error: "The latest changes couldn't be added. Your work is safe.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (run === 'other-remote') {
|
|
166
|
+
await answerFailed();
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 409,
|
|
170
|
+
error: "This branch uses a git remote other than the local one, so its latest changes can't be brought in here.",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (run === 'busy') {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
status: 409,
|
|
177
|
+
error: "This branch is busy with a save or another update, so the latest changes weren't added. Try again in a moment.",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
current = await BranchMetadataFileManager.loadOnly(branchContext.branchRoot);
|
|
181
|
+
if (current?.branch.name !== branch.name) {
|
|
182
|
+
return { ok: false, status: 404, error: 'This branch no longer exists.' };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// A wake-up already queued serves the new request too.
|
|
186
|
+
if (!runsInRequest && !joined && !queued) {
|
|
187
|
+
try {
|
|
188
|
+
await enqueueTask(taskDir, { action: SYNC_BRANCH_ACTION, payload: { branch: branch.name } });
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
// The worker's next regular sync answers the recorded request anyway, only later.
|
|
192
|
+
console.warn(`CanopyCMS: Could not queue the request for the latest changes on ${branch.name}:`, getErrorMessage(err));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
status: 202,
|
|
198
|
+
data: { requestedAt, branch: toBranchListItem(config, current.branch, req.user) },
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
/** Get the latest changes: bring the base branch into this one at the worker's next chance. */
|
|
202
|
+
export const updateFromBase = defineEndpoint({
|
|
203
|
+
namespace: 'workflow',
|
|
204
|
+
name: 'updateFromBase',
|
|
205
|
+
// Commits saved work on the shared filesystem before answering.
|
|
206
|
+
clientTimeout: 'long',
|
|
207
|
+
method: 'POST',
|
|
208
|
+
path: '/:branch/update-from-base',
|
|
209
|
+
params: branchParamSchema,
|
|
210
|
+
responseType: 'UpdateFromBaseResponse',
|
|
211
|
+
response: {},
|
|
212
|
+
defaultMockData: {
|
|
213
|
+
requestedAt: '2024-01-01T00:00:00.000Z',
|
|
214
|
+
branch: {
|
|
215
|
+
name: 'test-branch',
|
|
216
|
+
status: 'editing',
|
|
217
|
+
access: {},
|
|
218
|
+
createdBy: 'user-1',
|
|
219
|
+
createdAt: '2024-01-01',
|
|
220
|
+
updatedAt: '2024-01-01',
|
|
221
|
+
baseUpdateRequestedAt: '2024-01-01T00:00:00.000Z',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
// As Withdraw: the handler's canPerformWorkflowAction decides who may ask.
|
|
225
|
+
guards: ['branch'],
|
|
226
|
+
handler: updateFromBaseHandler,
|
|
227
|
+
});
|
package/dist/api/branch.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface BranchListItem extends Omit<BranchMetadata, 'review' | 'keptOve
|
|
|
60
60
|
users: number;
|
|
61
61
|
groups: number;
|
|
62
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* The commit a branch in review held when it was listed, which Approve and Request changes send
|
|
65
|
+
* back to say what the reviewer saw (api/branch-review.ts). Only on a listing.
|
|
66
|
+
*/
|
|
67
|
+
headSha?: string;
|
|
63
68
|
}
|
|
64
69
|
/**
|
|
65
70
|
* Response type for branch creation and the workflow transitions. Carries the
|
|
@@ -82,6 +87,11 @@ export type BranchListResponse = ApiResponse<{
|
|
|
82
87
|
defaultBranch?: string;
|
|
83
88
|
/** Profiles of the listed branches' creators, in no order; unknown ids are absent. */
|
|
84
89
|
users: UserProfile[];
|
|
90
|
+
/**
|
|
91
|
+
* The server's clock as it answered, in ms since the epoch, so the editor times a wait for the
|
|
92
|
+
* latest changes against the clock that stamped the request rather than the browser's.
|
|
93
|
+
*/
|
|
94
|
+
serverTime?: number;
|
|
85
95
|
}>;
|
|
86
96
|
/** Response type for branch deletion */
|
|
87
97
|
export type BranchDeleteResponse = ApiResponse<{
|
package/dist/api/branch.js
CHANGED
|
@@ -11,7 +11,7 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
|
11
11
|
import { DELETE_NEEDS_ADMIN_REASON } from '../editor/copy.js';
|
|
12
12
|
import { getErrorMessage } from '../utils/error.js';
|
|
13
13
|
import { filePathExists } from '../utils/fs.js';
|
|
14
|
-
import { isNetworkRemoteUrl } from '../utils/git.js';
|
|
14
|
+
import { isNetworkRemoteUrl, readHeadCommitFromFiles } from '../utils/git.js';
|
|
15
15
|
import { parseCreatedBranchName, sanitizeBranchName, isSettingsBranchName } from '../paths/index.js';
|
|
16
16
|
import { GitManager } from '../git-manager.js';
|
|
17
17
|
import { syncDeleteRemoteBranch } from './github-sync.js';
|
|
@@ -521,16 +521,42 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
|
|
|
521
521
|
console.error('CanopyCMS: Failed to look up branch owners:', getErrorMessage(error));
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
|
+
const heads = await Promise.all(branches.map((context) => reviewedHead(ctx, context)));
|
|
524
525
|
return {
|
|
525
526
|
ok: true,
|
|
526
527
|
status: 200,
|
|
527
528
|
data: {
|
|
528
|
-
branches: branches.map((context) =>
|
|
529
|
+
branches: branches.map((context, i) => {
|
|
530
|
+
const item = toBranchListItem(ctx.services.config, context.branch, req.user);
|
|
531
|
+
const headSha = heads[i];
|
|
532
|
+
return headSha ? { ...item, headSha } : item;
|
|
533
|
+
}),
|
|
529
534
|
defaultBranch,
|
|
530
535
|
users,
|
|
536
|
+
serverTime: Date.now(),
|
|
531
537
|
},
|
|
532
538
|
};
|
|
533
539
|
};
|
|
540
|
+
/**
|
|
541
|
+
* A branch in review's head commit, from the clone's files, so a listing of many spawns no git;
|
|
542
|
+
* git reads only a layout the files can't answer. Undefined when unreadable: the editor then has
|
|
543
|
+
* nothing to send, and the review is refused until a listing reads it.
|
|
544
|
+
*/
|
|
545
|
+
const reviewedHead = async (ctx, { branch, branchRoot }) => {
|
|
546
|
+
if (branch.status !== 'submitted' && branch.status !== 'approved')
|
|
547
|
+
return undefined;
|
|
548
|
+
try {
|
|
549
|
+
const fromFiles = await readHeadCommitFromFiles(branchRoot);
|
|
550
|
+
if (fromFiles)
|
|
551
|
+
return fromFiles;
|
|
552
|
+
const git = ctx.services.createGitManagerFor(branchRoot, { baseBranch: branch.baseBranch });
|
|
553
|
+
return await git.headSha();
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
console.error(`CanopyCMS: Could not read the head of ${branch.name}:`, getErrorMessage(err));
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
534
560
|
/** A person's delete, as deletion by age never does, can take commits only the branch still has. */
|
|
535
561
|
const mayHoldLostCommits = (branch) => branch.unpublishedAtMerge === true ||
|
|
536
562
|
(branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined);
|
package/dist/api/client.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ 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
13
|
import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
|
|
14
|
-
import type {
|
|
14
|
+
import type { EntryConflictResponse, MergeConflictsResponse, ReleaseConflictsHoldResponse, ResolveEntryConflictBody, ResolveEntryConflictResponse } from './merge-conflicts.js';
|
|
15
|
+
import type { ApproveBody, BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
|
|
15
16
|
import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
|
|
16
17
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
17
18
|
import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
|
|
@@ -95,13 +96,25 @@ export declare class CanopyApiClient {
|
|
|
95
96
|
/** POST /:branch/restore-entry/...entryPath */
|
|
96
97
|
restore: (params: Record<string, string>, body: RestoreEntryBody, options?: RequestOptions) => Promise<RestoreEntryResponse>;
|
|
97
98
|
};
|
|
99
|
+
readonly conflicts: {
|
|
100
|
+
/** GET /:branch/conflicts */
|
|
101
|
+
list: (params: Record<string, string>, options?: RequestOptions) => Promise<MergeConflictsResponse>;
|
|
102
|
+
/** GET /:branch/conflicts/...entryPath */
|
|
103
|
+
entry: (params: Record<string, string>, options?: RequestOptions) => Promise<EntryConflictResponse>;
|
|
104
|
+
/** POST /:branch/resolve-entry/...entryPath */
|
|
105
|
+
resolveEntry: (params: Record<string, string>, body: ResolveEntryConflictBody, options?: RequestOptions) => Promise<ResolveEntryConflictResponse>;
|
|
106
|
+
/** POST /:branch/conflicts/release-hold */
|
|
107
|
+
releaseHold: (params: Record<string, string>, options?: RequestOptions) => Promise<ReleaseConflictsHoldResponse>;
|
|
108
|
+
};
|
|
98
109
|
readonly workflow: {
|
|
99
110
|
/** POST /:branch/withdraw */
|
|
100
111
|
withdraw: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
112
|
+
/** POST /:branch/update-from-base */
|
|
113
|
+
updateFromBase: (params: Record<string, string>, options?: RequestOptions) => Promise<UpdateFromBaseResponse>;
|
|
101
114
|
/** POST /:branch/request-changes */
|
|
102
115
|
requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
103
116
|
/** POST /:branch/approve */
|
|
104
|
-
approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
117
|
+
approve: (params: Record<string, string>, body: ApproveBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
105
118
|
/** POST /:branch/mark-merged */
|
|
106
119
|
markMerged: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
|
|
107
120
|
/** POST /:branch/archive */
|
package/dist/api/client.js
CHANGED
|
@@ -55,18 +55,40 @@ export class CanopyApiClient {
|
|
|
55
55
|
return this.request('POST', this.buildPath('/:branch/restore-entry/...entryPath', params), body, 'standard', options);
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
|
+
this.conflicts = {
|
|
59
|
+
/** GET /:branch/conflicts */
|
|
60
|
+
list: (params, options) => {
|
|
61
|
+
return this.request('GET', this.buildPath('/:branch/conflicts', params), undefined, 'standard', options);
|
|
62
|
+
},
|
|
63
|
+
/** GET /:branch/conflicts/...entryPath */
|
|
64
|
+
entry: (params, options) => {
|
|
65
|
+
return this.request('GET', this.buildPath('/:branch/conflicts/...entryPath', params), undefined, 'standard', options);
|
|
66
|
+
},
|
|
67
|
+
/** POST /:branch/resolve-entry/...entryPath */
|
|
68
|
+
resolveEntry: (params, body, options) => {
|
|
69
|
+
return this.request('POST', this.buildPath('/:branch/resolve-entry/...entryPath', params), body, 'standard', options);
|
|
70
|
+
},
|
|
71
|
+
/** POST /:branch/conflicts/release-hold */
|
|
72
|
+
releaseHold: (params, options) => {
|
|
73
|
+
return this.request('POST', this.buildPath('/:branch/conflicts/release-hold', params), undefined, 'standard', options);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
58
76
|
this.workflow = {
|
|
59
77
|
/** POST /:branch/withdraw */
|
|
60
78
|
withdraw: (params, options) => {
|
|
61
79
|
return this.request('POST', this.buildPath('/:branch/withdraw', params), undefined, 'standard', options);
|
|
62
80
|
},
|
|
81
|
+
/** POST /:branch/update-from-base */
|
|
82
|
+
updateFromBase: (params, options) => {
|
|
83
|
+
return this.request('POST', this.buildPath('/:branch/update-from-base', params), undefined, 'long', options);
|
|
84
|
+
},
|
|
63
85
|
/** POST /:branch/request-changes */
|
|
64
86
|
requestChanges: (params, body, options) => {
|
|
65
87
|
return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
|
|
66
88
|
},
|
|
67
89
|
/** POST /:branch/approve */
|
|
68
|
-
approve: (params, options) => {
|
|
69
|
-
return this.request('POST', this.buildPath('/:branch/approve', params),
|
|
90
|
+
approve: (params, body, options) => {
|
|
91
|
+
return this.request('POST', this.buildPath('/:branch/approve', params), body, 'standard', options);
|
|
70
92
|
},
|
|
71
93
|
/** POST /:branch/mark-merged */
|
|
72
94
|
markMerged: (params, options) => {
|
package/dist/api/comments.d.ts
CHANGED
|
@@ -60,16 +60,16 @@ export declare const COMMENT_ROUTES: {
|
|
|
60
60
|
}, "strip", z.ZodTypeAny, {
|
|
61
61
|
type: "branch" | "entry" | "field";
|
|
62
62
|
text: string;
|
|
63
|
+
entryPath?: LogicalPath | undefined;
|
|
63
64
|
threadId?: string | undefined;
|
|
64
65
|
itemKeys?: string[] | undefined;
|
|
65
|
-
entryPath?: LogicalPath | undefined;
|
|
66
66
|
canopyPath?: string | undefined;
|
|
67
67
|
}, {
|
|
68
68
|
type: "branch" | "entry" | "field";
|
|
69
69
|
text: string;
|
|
70
|
+
entryPath?: LogicalPath | undefined;
|
|
70
71
|
threadId?: string | undefined;
|
|
71
72
|
itemKeys?: string[] | undefined;
|
|
72
|
-
entryPath?: LogicalPath | undefined;
|
|
73
73
|
canopyPath?: string | undefined;
|
|
74
74
|
}>, AddCommentResponse>;
|
|
75
75
|
readonly resolve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
package/dist/api/content.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ApiResponse } from './types.js';
|
|
3
|
-
import type { EntryValidationIssue } from '../config/index.js';
|
|
3
|
+
import type { ContentFormat, EntrySchema, EntryValidationIssue } from '../config/index.js';
|
|
4
4
|
import { type LogicalPath, type Slug } from '../paths/index.js';
|
|
5
|
+
import type { ResolvedFieldConflict } from '../merge-conflicts.js';
|
|
5
6
|
export type ContentReadResponse = ApiResponse<{
|
|
6
7
|
format: string;
|
|
7
8
|
data: Record<string, unknown>;
|
|
@@ -22,6 +23,11 @@ export type ContentWriteResponse = ApiResponse<{
|
|
|
22
23
|
* editor has exactly one save-warnings notification to render (useEntryManager.ts).
|
|
23
24
|
*/
|
|
24
25
|
validationWarnings?: EntryValidationIssue[];
|
|
26
|
+
/**
|
|
27
|
+
* Of the `resolvedConflicts` sent with an update, the ones this save cleared: one sent but not
|
|
28
|
+
* here was stale, unless a warning says the choice wasn't recorded.
|
|
29
|
+
*/
|
|
30
|
+
resolvedConflicts?: ResolvedFieldConflict[];
|
|
25
31
|
}>;
|
|
26
32
|
export type ReferenceValidationResponse = ApiResponse<{
|
|
27
33
|
valid: boolean;
|
|
@@ -48,6 +54,13 @@ export interface WriteContentBody {
|
|
|
48
54
|
* from "lost the token", so an update has to prove which version it read.
|
|
49
55
|
*/
|
|
50
56
|
expectedVersion?: number | null;
|
|
57
|
+
/**
|
|
58
|
+
* Field conflicts a merge left on this entry that the saved values resolve, each named as
|
|
59
|
+
* `GET /:branch/conflicts/...entryPath` names it. Cleared once the entry is written, under the
|
|
60
|
+
* save's locks; a pair the record no longer holds is ignored. A save that leaves a conflicting
|
|
61
|
+
* field out leaves its conflict open.
|
|
62
|
+
*/
|
|
63
|
+
resolvedConflicts?: ResolvedFieldConflict[];
|
|
51
64
|
}
|
|
52
65
|
export interface ValidateReferencesBody {
|
|
53
66
|
data: Record<string, unknown>;
|
|
@@ -55,6 +68,12 @@ export interface ValidateReferencesBody {
|
|
|
55
68
|
export interface RenameEntryBody {
|
|
56
69
|
newSlug: string;
|
|
57
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* What a save writes for `data`, an entry's form data: an md/mdx file's body-field key dropped
|
|
73
|
+
* (that field is the file's body), references collapsed to IDs, and every list item and block
|
|
74
|
+
* keyed. `backfill-item-keys` writes through this too, so its files are a save's.
|
|
75
|
+
*/
|
|
76
|
+
export declare function entryDataAsSaved(fields: EntrySchema, format: ContentFormat, data: Record<string, unknown>): Record<string, unknown>;
|
|
58
77
|
/**
|
|
59
78
|
* Exported routes for router registration
|
|
60
79
|
*/
|
|
@@ -102,16 +121,34 @@ export declare const CONTENT_ROUTES: {
|
|
|
102
121
|
data: z.ZodOptional<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodUnknown>, Record<string, unknown>, Record<string, unknown>>>;
|
|
103
122
|
body: z.ZodOptional<z.ZodString>;
|
|
104
123
|
expectedVersion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
124
|
+
resolvedConflicts: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
125
|
+
mergeSha: z.ZodString;
|
|
126
|
+
key: z.ZodString;
|
|
127
|
+
}, "strip", z.ZodTypeAny, {
|
|
128
|
+
key: string;
|
|
129
|
+
mergeSha: string;
|
|
130
|
+
}, {
|
|
131
|
+
key: string;
|
|
132
|
+
mergeSha: string;
|
|
133
|
+
}>, "many">>;
|
|
105
134
|
}, "strip", z.ZodTypeAny, {
|
|
106
135
|
format: "md" | "mdx" | "json" | "yaml";
|
|
107
136
|
data?: Record<string, unknown> | undefined;
|
|
108
137
|
expectedVersion?: number | null | undefined;
|
|
109
138
|
body?: string | undefined;
|
|
139
|
+
resolvedConflicts?: {
|
|
140
|
+
key: string;
|
|
141
|
+
mergeSha: string;
|
|
142
|
+
}[] | undefined;
|
|
110
143
|
}, {
|
|
111
144
|
format: "md" | "mdx" | "json" | "yaml";
|
|
112
145
|
data?: Record<string, unknown> | undefined;
|
|
113
146
|
expectedVersion?: number | null | undefined;
|
|
114
147
|
body?: string | undefined;
|
|
148
|
+
resolvedConflicts?: {
|
|
149
|
+
key: string;
|
|
150
|
+
mergeSha: string;
|
|
151
|
+
}[] | undefined;
|
|
115
152
|
}>, ContentWriteResponse>;
|
|
116
153
|
readonly validateReferences: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
117
154
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
package/dist/api/content.js
CHANGED
|
@@ -14,6 +14,8 @@ import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils
|
|
|
14
14
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
15
15
|
import { withItemKeys } from '../item-keys.js';
|
|
16
16
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
17
|
+
import { canopyLogWarn } from '../utils/logger.js';
|
|
18
|
+
import { clearFieldConflicts, resolvedConflictsSchema } from './merge-conflicts.js';
|
|
17
19
|
/**
|
|
18
20
|
* Parse an API path into logical path segments, prepending the content root if needed.
|
|
19
21
|
* Shared by all content API handlers.
|
|
@@ -60,6 +62,7 @@ const writeContentBodySchema = z.object({
|
|
|
60
62
|
body: z.string().max(MAX_CONTENT_BODY_CHARS).optional(),
|
|
61
63
|
// null or omitted = create ("must not already exist"); see WriteContentBody.
|
|
62
64
|
expectedVersion: z.number().nullish(),
|
|
65
|
+
resolvedConflicts: resolvedConflictsSchema.optional(),
|
|
63
66
|
});
|
|
64
67
|
const validateReferencesParamsSchema = z.object({
|
|
65
68
|
branch: branchNameSchema,
|
|
@@ -133,6 +136,15 @@ function withoutKey(record, key) {
|
|
|
133
136
|
const { [key]: _dropped, ...rest } = record;
|
|
134
137
|
return rest;
|
|
135
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* What a save writes for `data`, an entry's form data: an md/mdx file's body-field key dropped
|
|
141
|
+
* (that field is the file's body), references collapsed to IDs, and every list item and block
|
|
142
|
+
* keyed. `backfill-item-keys` writes through this too, so its files are a save's.
|
|
143
|
+
*/
|
|
144
|
+
export function entryDataAsSaved(fields, format, data) {
|
|
145
|
+
const own = isDataOnlyFormat(format) ? data : withoutKey(data, findBodyFieldName(fields));
|
|
146
|
+
return withItemKeys(fields, normalizeReferenceValues(fields, own), 'content');
|
|
147
|
+
}
|
|
136
148
|
/** The policy's issues in the stored entry, which a save may keep. */
|
|
137
149
|
const storedMarkdownSafetyIssues = async (store, collectionPath, slug, fields, siteAllow) => {
|
|
138
150
|
// A stored file that does not read keeps nothing, so the save's code is refused, not a 500.
|
|
@@ -393,9 +405,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
393
405
|
// Every list item and block leaves a save keyed (`item-keys.ts`). A key sent is kept, so a
|
|
394
406
|
// thread naming it still finds its item; one missing is filled from the item's content, as a
|
|
395
407
|
// read fills one.
|
|
396
|
-
const normalizedData = requestData === undefined
|
|
397
|
-
? undefined
|
|
398
|
-
: withItemKeys(fields, normalizeReferenceValues(fields, requestData), 'content');
|
|
408
|
+
const normalizedData = requestData === undefined ? undefined : entryDataAsSaved(fields, body.format, requestData);
|
|
399
409
|
// Computed before the validateEntry hook and the entry-link scan too, not just the write, so
|
|
400
410
|
// every consumer agrees on the same bytes — otherwise an adopter's hook could see a resolved
|
|
401
411
|
// object while the file got an ID string, depending on whether the post came from the editor.
|
|
@@ -475,10 +485,32 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
475
485
|
body: body.body ?? '',
|
|
476
486
|
expectedVersion,
|
|
477
487
|
};
|
|
488
|
+
// Under the write's own locks, so no merge records a conflict between the value written and
|
|
489
|
+
// the record cleared. A failure to clear keeps the save: the conflict stays open, and says so.
|
|
490
|
+
let resolvedConflicts;
|
|
491
|
+
const resolving = expectedVersion === null ? [] : (body.resolvedConflicts ?? []);
|
|
492
|
+
const afterWrite = async ({ id }) => {
|
|
493
|
+
if (!id || resolving.length === 0)
|
|
494
|
+
return;
|
|
495
|
+
try {
|
|
496
|
+
resolvedConflicts = await clearFieldConflicts(ctx.services.config, branchContext, id, resolving);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
canopyLogWarn(`CanopyCMS: saved ${logicalPathSegments.join('/')} but couldn't mark its conflicts resolved:`, getErrorMessage(err));
|
|
500
|
+
resolvedConflicts = [];
|
|
501
|
+
validationWarnings = [
|
|
502
|
+
...(validationWarnings ?? []),
|
|
503
|
+
{
|
|
504
|
+
level: 'warning',
|
|
505
|
+
message: 'Saved, but your choice for its conflicting changes wasn’t recorded. Choose again, then save.',
|
|
506
|
+
},
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
};
|
|
478
510
|
// Pass the resolved entryTypeName (not the raw, possibly-omitted
|
|
479
511
|
// params.entryType) so the store's own format check agrees with the type
|
|
480
512
|
// we just validated against.
|
|
481
|
-
const result = await store.write(schemaItem.logicalPath, slug, writeInput, entryTypeName);
|
|
513
|
+
const result = await store.write(schemaItem.logicalPath, slug, writeInput, entryTypeName, undefined, { afterWrite });
|
|
482
514
|
// Validate entry links in body content (warnings only, don't block save).
|
|
483
515
|
// Reuses the entry-type fields resolved above for schema validation. Folded into
|
|
484
516
|
// `validationWarnings` (not a separate `entryLinkWarnings` field) so the editor's one
|
|
@@ -494,7 +526,11 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
494
526
|
}));
|
|
495
527
|
validationWarnings = [...(validationWarnings ?? []), ...linkWarnings];
|
|
496
528
|
}
|
|
497
|
-
return {
|
|
529
|
+
return {
|
|
530
|
+
ok: true,
|
|
531
|
+
status: 200,
|
|
532
|
+
data: { ...result, validationWarnings, ...(resolvedConflicts && { resolvedConflicts }) },
|
|
533
|
+
};
|
|
498
534
|
}
|
|
499
535
|
catch (err) {
|
|
500
536
|
if (err instanceof ContentConflictError) {
|