canopycms 0.7.0 → 0.8.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 +201 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1060 -172
- package/dist/cli/generate-ai-content.js +732 -101
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.js +23 -16
- package/dist/context.js +8 -7
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +47 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +40 -25
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- package/package.json +2 -1
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What changed in one entry between two saved states, field by field, for the "Show changes"
|
|
3
|
+
* drawer. Pure and client-safe: it walks the entry type's schema, so each change carries the
|
|
4
|
+
* labels the form shows, and lists and blocks match their items by item key (`item-keys.ts`),
|
|
5
|
+
* so a reordered item reads as moved, not as one removed and one added.
|
|
6
|
+
*/
|
|
7
|
+
import { diffLines, diffWordsWithSpace } from 'diff';
|
|
8
|
+
import equal from 'fast-deep-equal';
|
|
9
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
10
|
+
import { findBodyFieldName } from '../utils/body-field.js';
|
|
11
|
+
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
12
|
+
import { isPlainRecord, resolveBlockItem } from '../validation/field-traversal.js';
|
|
13
|
+
import { listItemHeading, listItemTitle } from './fields/list-item-title.js';
|
|
14
|
+
import { humanizeName, schemaLabel } from './schema-label.js';
|
|
15
|
+
/** Text at most this long, on one line, gets a word diff; longer text a line diff. */
|
|
16
|
+
const SHORT_TEXT = 200;
|
|
17
|
+
/** Unchanged lines kept on each side of a change in a line diff. */
|
|
18
|
+
const CONTEXT_LINES = 2;
|
|
19
|
+
/**
|
|
20
|
+
* How long one entry's line and long-text diffing may take in all, on the editor's main thread,
|
|
21
|
+
* where jsdiff's run time grows with the square of the edits. The deadline is checked before each
|
|
22
|
+
* jsdiff call, and each call is given only the time left; past it, every such change left is
|
|
23
|
+
* shown unmarked.
|
|
24
|
+
*/
|
|
25
|
+
const DIFF_TIMEOUT_MS = 300;
|
|
26
|
+
/**
|
|
27
|
+
* The absolute deadline for short word diffs, measured from the start of the entry. They cost
|
|
28
|
+
* about a millisecond at most for prose, a few for 200 characters of punctuation, so they get a
|
|
29
|
+
* longer budget rather than none; past it they too are shown unmarked.
|
|
30
|
+
*/
|
|
31
|
+
const SHORT_DIFF_TIMEOUT_MS = 1000;
|
|
32
|
+
const OTHER_FIELDS_LABEL = 'Other fields';
|
|
33
|
+
const LONG_TEXT_TYPES = new Set(['markdown', 'mdx', 'code']);
|
|
34
|
+
/** A side's data with an md/mdx body under that side's body field, as the form holds it. */
|
|
35
|
+
const formValue = (fields, side) => {
|
|
36
|
+
if (!side)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (side.body === undefined)
|
|
39
|
+
return side.data;
|
|
40
|
+
return { ...side.data, [side.bodyFieldName ?? findBodyFieldName(fields)]: side.body };
|
|
41
|
+
};
|
|
42
|
+
const isEmpty = (value) => value === undefined ||
|
|
43
|
+
value === null ||
|
|
44
|
+
value === '' ||
|
|
45
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
46
|
+
(isPlainRecord(value) && Object.keys(value).every((k) => k === ITEM_KEY || isEmpty(value[k])));
|
|
47
|
+
/** `value` without item keys and empty members, so equal content compares equal. */
|
|
48
|
+
const normalized = (value) => {
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return value.map(normalized);
|
|
51
|
+
if (!isPlainRecord(value))
|
|
52
|
+
return value;
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [k, v] of Object.entries(value)) {
|
|
55
|
+
if (k !== ITEM_KEY && !isEmpty(v))
|
|
56
|
+
out[k] = normalized(v);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
const sameValue = (a, b) => (isEmpty(a) && isEmpty(b)) || equal(normalized(a), normalized(b));
|
|
61
|
+
const at = (parent, label, key) => ({
|
|
62
|
+
path: [...parent.path, label],
|
|
63
|
+
key: parent.key ? `${parent.key}.${key}` : key,
|
|
64
|
+
});
|
|
65
|
+
/** A member the schema doesn't name, under an "Other fields" crumb. */
|
|
66
|
+
const otherAt = (parent, name) => ({
|
|
67
|
+
path: [...parent.path, OTHER_FIELDS_LABEL, humanizeName(name)],
|
|
68
|
+
key: `${parent.key}?${name}`,
|
|
69
|
+
});
|
|
70
|
+
/** The file name of an image `src`, its query and fragment dropped. */
|
|
71
|
+
const fileName = (src) => {
|
|
72
|
+
const last = src.split(/[?#]/)[0].split('/').filter(Boolean).pop() ?? src;
|
|
73
|
+
try {
|
|
74
|
+
return decodeURIComponent(last);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return last;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const rawText = (value) => typeof value === 'string' ? value : (JSON.stringify(normalized(value), null, 2) ?? String(value));
|
|
81
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
82
|
+
const ISO_DATETIME_START = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
83
|
+
const ISO_CHARS = /^[\d:.+\-TZ]+$/;
|
|
84
|
+
/**
|
|
85
|
+
* A date as a comparison shows it: absolute, in the viewer's locale, with the time zone. The
|
|
86
|
+
* editor's dates are otherwise relative, but two relative dates can read alike while their values
|
|
87
|
+
* differ, which is the one thing a comparison must show.
|
|
88
|
+
*/
|
|
89
|
+
const formatDate = (iso, dateOnly, precision = 'minute') => {
|
|
90
|
+
if (dateOnly) {
|
|
91
|
+
const [year, month, day] = iso.split('-').map(Number);
|
|
92
|
+
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
|
|
93
|
+
year: 'numeric',
|
|
94
|
+
month: 'short',
|
|
95
|
+
day: 'numeric',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
99
|
+
year: 'numeric',
|
|
100
|
+
month: 'short',
|
|
101
|
+
day: 'numeric',
|
|
102
|
+
hour: 'numeric',
|
|
103
|
+
minute: '2-digit',
|
|
104
|
+
...(precision !== 'minute' && { second: '2-digit' }),
|
|
105
|
+
...(precision === 'millisecond' && { fractionalSecondDigits: 3 }),
|
|
106
|
+
timeZoneName: 'short',
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
/** Two ISO date-time values naming one instant, however spelled: no change. Free text is not parsed. */
|
|
110
|
+
const sameInstant = (field, a, b) => {
|
|
111
|
+
if (field?.type !== 'datetime' || typeof a !== 'string' || typeof b !== 'string')
|
|
112
|
+
return false;
|
|
113
|
+
const iso = (s) => DATE_ONLY.test(s) || (ISO_DATETIME_START.test(s) && ISO_CHARS.test(s));
|
|
114
|
+
if (!iso(a) || !iso(b))
|
|
115
|
+
return false;
|
|
116
|
+
const [x, y] = [Date.parse(a), Date.parse(b)];
|
|
117
|
+
return !Number.isNaN(x) && x === y;
|
|
118
|
+
};
|
|
119
|
+
/** Date-times in the same minute are shown to the second, in the same second to the millisecond. */
|
|
120
|
+
const withSecondsWhenNeeded = (before, after) => {
|
|
121
|
+
if (before.dateOnly || after.dateOnly)
|
|
122
|
+
return;
|
|
123
|
+
const [x, y] = [Date.parse(before.iso), Date.parse(after.iso)];
|
|
124
|
+
if (Math.floor(x / 60_000) !== Math.floor(y / 60_000))
|
|
125
|
+
return;
|
|
126
|
+
const precision = Math.floor(x / 1000) === Math.floor(y / 1000) ? 'millisecond' : 'second';
|
|
127
|
+
before.text = formatDate(before.iso, false, precision);
|
|
128
|
+
after.text = formatDate(after.iso, false, precision);
|
|
129
|
+
};
|
|
130
|
+
/** The text a display marks word by word in a change: text values', and dates' absolute form. */
|
|
131
|
+
const markableText = (value) => value?.kind === 'text' || value?.kind === 'date' ? value.text : undefined;
|
|
132
|
+
class Walker {
|
|
133
|
+
constructor(referenceTitle,
|
|
134
|
+
/** The `Date.now()` past which no more line or long-text diffs start. */
|
|
135
|
+
deadline,
|
|
136
|
+
/** The `Date.now()` past which no more short word diffs run. */
|
|
137
|
+
wordDeadline) {
|
|
138
|
+
this.referenceTitle = referenceTitle;
|
|
139
|
+
this.deadline = deadline;
|
|
140
|
+
this.wordDeadline = wordDeadline;
|
|
141
|
+
this.changes = [];
|
|
142
|
+
}
|
|
143
|
+
/** `value` of `field`, as the drawer shows it. */
|
|
144
|
+
display(field, value) {
|
|
145
|
+
if (isEmpty(value))
|
|
146
|
+
return { kind: 'empty' };
|
|
147
|
+
if (field && 'list' in field && field.list && field.type !== 'object') {
|
|
148
|
+
const items = Array.isArray(value) ? value : [value];
|
|
149
|
+
return {
|
|
150
|
+
kind: 'list',
|
|
151
|
+
items: items.map((item) => this.display({ ...field, list: false }, item)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
switch (field?.type) {
|
|
155
|
+
case 'object': {
|
|
156
|
+
const objectField = field;
|
|
157
|
+
if (objectField.list && Array.isArray(value)) {
|
|
158
|
+
return {
|
|
159
|
+
kind: 'list',
|
|
160
|
+
items: value.map((item) => this.record(objectField.fields, item)),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return this.record(objectField.fields, value);
|
|
164
|
+
}
|
|
165
|
+
case 'block': {
|
|
166
|
+
const blockField = field;
|
|
167
|
+
const items = Array.isArray(value) ? value : [];
|
|
168
|
+
return { kind: 'list', items: items.map((item) => this.blockRecord(blockField, item)) };
|
|
169
|
+
}
|
|
170
|
+
case 'reference':
|
|
171
|
+
if (typeof value === 'string') {
|
|
172
|
+
const title = this.referenceTitle?.(value);
|
|
173
|
+
return { kind: 'reference', id: value, ...(title !== undefined && { title }) };
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
case 'image':
|
|
177
|
+
if (isPlainRecord(value) && typeof value.src === 'string') {
|
|
178
|
+
return {
|
|
179
|
+
kind: 'image',
|
|
180
|
+
src: value.src,
|
|
181
|
+
name: fileName(value.src),
|
|
182
|
+
...(typeof value.alt === 'string' && value.alt !== '' && { alt: value.alt }),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
case 'select': {
|
|
187
|
+
const option = field.options?.find((o) => (typeof o === 'string' ? o : o.value) === value);
|
|
188
|
+
if (option !== undefined) {
|
|
189
|
+
return { kind: 'select', label: typeof option === 'string' ? option : option.label };
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'datetime':
|
|
194
|
+
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) {
|
|
195
|
+
return DATE_ONLY.test(value)
|
|
196
|
+
? { kind: 'date', iso: value, text: formatDate(value, true), dateOnly: true }
|
|
197
|
+
: { kind: 'date', iso: value, text: formatDate(value, false) };
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (typeof value === 'string') {
|
|
204
|
+
return field && LONG_TEXT_TYPES.has(field.type)
|
|
205
|
+
? { kind: 'text', text: value, long: true }
|
|
206
|
+
: { kind: 'text', text: value };
|
|
207
|
+
}
|
|
208
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
209
|
+
return { kind: 'number', value };
|
|
210
|
+
if (typeof value === 'boolean')
|
|
211
|
+
return { kind: 'boolean', value };
|
|
212
|
+
return { kind: 'raw', text: rawText(value) };
|
|
213
|
+
}
|
|
214
|
+
/** A record's non-empty fields, schema fields first, then the ones it doesn't describe. */
|
|
215
|
+
record(fields, value) {
|
|
216
|
+
if (!isPlainRecord(value))
|
|
217
|
+
return this.display(undefined, value);
|
|
218
|
+
const flat = flattenGroupFields(fields);
|
|
219
|
+
const known = new Set(flat.map((f) => f.name));
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const field of flat) {
|
|
222
|
+
if (!isEmpty(value[field.name])) {
|
|
223
|
+
out.push({ label: schemaLabel(field), value: this.display(field, value[field.name]) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const [name, v] of Object.entries(value)) {
|
|
227
|
+
if (!known.has(name) && name !== ITEM_KEY && !isEmpty(v)) {
|
|
228
|
+
out.push({ label: humanizeName(name), value: this.display(undefined, v) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out.length === 0 ? { kind: 'empty' } : { kind: 'fields', fields: out };
|
|
232
|
+
}
|
|
233
|
+
blockRecord(field, item) {
|
|
234
|
+
const resolved = isPlainRecord(item) ? resolveBlockItem(field, item) : undefined;
|
|
235
|
+
return resolved ? this.record(resolved.fields, resolved.data) : this.display(undefined, item);
|
|
236
|
+
}
|
|
237
|
+
/** Compares two records by `fields`, then the members the schema doesn't name. */
|
|
238
|
+
fields(fields, before, after, where) {
|
|
239
|
+
const flat = flattenGroupFields(fields);
|
|
240
|
+
const known = new Set(flat.map((f) => f.name));
|
|
241
|
+
for (const field of flat) {
|
|
242
|
+
this.field(field, before?.[field.name], after?.[field.name], at(where, schemaLabel(field), field.name));
|
|
243
|
+
}
|
|
244
|
+
const others = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
|
|
245
|
+
for (const name of others) {
|
|
246
|
+
if (known.has(name) || name === ITEM_KEY)
|
|
247
|
+
continue;
|
|
248
|
+
this.value(undefined, before?.[name], after?.[name], otherAt(where, name));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
field(field, before, after, where) {
|
|
252
|
+
if (sameValue(before, after))
|
|
253
|
+
return;
|
|
254
|
+
if (field.type === 'block') {
|
|
255
|
+
this.list(before, after, where, {
|
|
256
|
+
heading: (item) => this.blockHeading(field, item),
|
|
257
|
+
compare: (b, a, itemAt) => this.block(field, b, a, itemAt),
|
|
258
|
+
display: (item) => this.blockRecord(field, item),
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (field.type === 'object') {
|
|
263
|
+
const objectField = field;
|
|
264
|
+
if (objectField.list) {
|
|
265
|
+
this.list(before, after, where, {
|
|
266
|
+
heading: (item, index) => listItemHeading(item, objectField, index),
|
|
267
|
+
compare: (b, a, itemAt) => this.fields(objectField.fields, asRecord(b), asRecord(a), itemAt),
|
|
268
|
+
display: (item) => this.record(objectField.fields, item),
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if ((isPlainRecord(before) || isEmpty(before)) && (isPlainRecord(after) || isEmpty(after))) {
|
|
273
|
+
this.fields(objectField.fields ?? [], asRecord(before), asRecord(after), where);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this.value(field, before, after, where);
|
|
278
|
+
}
|
|
279
|
+
/** One leaf change: a value set, cleared or changed. */
|
|
280
|
+
value(field, before, after, where) {
|
|
281
|
+
if (sameValue(before, after) || sameInstant(field, before, after))
|
|
282
|
+
return;
|
|
283
|
+
const kind = isEmpty(before) ? 'added' : isEmpty(after) ? 'removed' : 'changed';
|
|
284
|
+
const change = { key: where.key, path: where.path, kind };
|
|
285
|
+
if (kind !== 'added')
|
|
286
|
+
change.before = this.display(field, before);
|
|
287
|
+
if (kind !== 'removed')
|
|
288
|
+
change.after = this.display(field, after);
|
|
289
|
+
// Text and dates only: a reference's id is a value, shown whole.
|
|
290
|
+
const beforeText = markableText(change.before);
|
|
291
|
+
const afterText = markableText(change.after);
|
|
292
|
+
if (beforeText !== undefined &&
|
|
293
|
+
afterText !== undefined &&
|
|
294
|
+
change.before?.kind === change.after?.kind) {
|
|
295
|
+
const long = change.after?.kind === 'text' && !!change.after.long;
|
|
296
|
+
if (change.before?.kind === 'date' && change.after?.kind === 'date') {
|
|
297
|
+
withSecondsWhenNeeded(change.before, change.after);
|
|
298
|
+
}
|
|
299
|
+
const text = textDiff(markableText(change.before) ?? beforeText, markableText(change.after) ?? afterText, long, this.deadline, this.wordDeadline);
|
|
300
|
+
if (text)
|
|
301
|
+
change.text = text;
|
|
302
|
+
else
|
|
303
|
+
change.tooLargeToMark = true;
|
|
304
|
+
}
|
|
305
|
+
if (change.before?.kind === 'image' &&
|
|
306
|
+
change.after?.kind === 'image' &&
|
|
307
|
+
change.before.alt !== undefined &&
|
|
308
|
+
change.after.alt !== undefined &&
|
|
309
|
+
change.before.alt !== change.after.alt) {
|
|
310
|
+
const alt = textDiff(change.before.alt, change.after.alt, false, this.deadline, this.wordDeadline);
|
|
311
|
+
if (alt)
|
|
312
|
+
change.altText = alt;
|
|
313
|
+
}
|
|
314
|
+
this.changes.push(change);
|
|
315
|
+
}
|
|
316
|
+
blockHeading(field, item) {
|
|
317
|
+
const record = isPlainRecord(item) ? item : {};
|
|
318
|
+
const templateName = typeof record.template === 'string' ? record.template : undefined;
|
|
319
|
+
const template = field.templates.find((t) => t.name === templateName);
|
|
320
|
+
const label = template
|
|
321
|
+
? schemaLabel(template)
|
|
322
|
+
: templateName
|
|
323
|
+
? humanizeName(templateName)
|
|
324
|
+
: 'Block';
|
|
325
|
+
const resolved = resolveBlockItem(field, record);
|
|
326
|
+
const title = resolved ? listItemTitle(resolved.data, { fields: resolved.fields }) : undefined;
|
|
327
|
+
return title ? `${label}: ${title}` : label;
|
|
328
|
+
}
|
|
329
|
+
block(field, before, after, where) {
|
|
330
|
+
const b = isPlainRecord(before) ? resolveBlockItem(field, before) : undefined;
|
|
331
|
+
const a = isPlainRecord(after) ? resolveBlockItem(field, after) : undefined;
|
|
332
|
+
const sameTemplate = isPlainRecord(before) && isPlainRecord(after) && before.template === after.template;
|
|
333
|
+
if (b && a && sameTemplate) {
|
|
334
|
+
this.fields(a.fields, b.data, a.data, where);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (sameValue(before, after))
|
|
338
|
+
return;
|
|
339
|
+
this.changes.push({
|
|
340
|
+
key: where.key,
|
|
341
|
+
path: where.path,
|
|
342
|
+
kind: 'changed',
|
|
343
|
+
before: this.blockRecord(field, before),
|
|
344
|
+
after: this.blockRecord(field, after),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Compares two lists item by item, matched by item key: an item on one side only is added or
|
|
349
|
+
* removed, and one whose place among the matched items changed is moved. Items keep their
|
|
350
|
+
* order relative to each other when they are on the longest common run of the two orders,
|
|
351
|
+
* so moving one item marks that item alone.
|
|
352
|
+
*/
|
|
353
|
+
list(before, after, where, item) {
|
|
354
|
+
const bItems = Array.isArray(before) ? before : [];
|
|
355
|
+
const aItems = Array.isArray(after) ? after : [];
|
|
356
|
+
const keyOf = (value, index, side) => isPlainRecord(value) && typeof value[ITEM_KEY] === 'string'
|
|
357
|
+
? value[ITEM_KEY]
|
|
358
|
+
: `${side}#${index}`;
|
|
359
|
+
const bKeys = bItems.map((v, i) => keyOf(v, i, 'before'));
|
|
360
|
+
const aKeys = aItems.map((v, i) => keyOf(v, i, 'after'));
|
|
361
|
+
const bIndex = new Map(bKeys.map((k, i) => [k, i]));
|
|
362
|
+
const aSet = new Set(aKeys);
|
|
363
|
+
const shared = aKeys.filter((k) => bIndex.has(k));
|
|
364
|
+
const stayed = longestCommonRun(bKeys.filter((k) => aSet.has(k)), shared);
|
|
365
|
+
aItems.forEach((value, index) => {
|
|
366
|
+
const key = aKeys[index];
|
|
367
|
+
const from = bIndex.get(key);
|
|
368
|
+
// A matched item is named as it was at the base, so a change to its title doesn't rename
|
|
369
|
+
// the path to it.
|
|
370
|
+
const itemAt = at(where, from === undefined ? item.heading(value, index) : item.heading(bItems[from], from), key);
|
|
371
|
+
if (from === undefined) {
|
|
372
|
+
this.changes.push({
|
|
373
|
+
key: itemAt.key,
|
|
374
|
+
path: itemAt.path,
|
|
375
|
+
kind: 'added',
|
|
376
|
+
item: true,
|
|
377
|
+
after: item.display(value),
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!stayed.has(key)) {
|
|
382
|
+
this.changes.push({
|
|
383
|
+
key: `${itemAt.key}#moved`,
|
|
384
|
+
path: itemAt.path,
|
|
385
|
+
kind: 'moved',
|
|
386
|
+
item: true,
|
|
387
|
+
before: { kind: 'position', position: from + 1, of: bItems.length },
|
|
388
|
+
after: { kind: 'position', position: index + 1, of: aItems.length },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
item.compare(bItems[from], value, itemAt);
|
|
392
|
+
});
|
|
393
|
+
bItems.forEach((value, index) => {
|
|
394
|
+
const key = bKeys[index];
|
|
395
|
+
if (aSet.has(key))
|
|
396
|
+
return;
|
|
397
|
+
const itemAt = at(where, item.heading(value, index), key);
|
|
398
|
+
this.changes.push({
|
|
399
|
+
key: itemAt.key,
|
|
400
|
+
path: itemAt.path,
|
|
401
|
+
kind: 'removed',
|
|
402
|
+
item: true,
|
|
403
|
+
before: item.display(value),
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const asRecord = (value) => isPlainRecord(value) ? value : undefined;
|
|
409
|
+
/**
|
|
410
|
+
* The keys on a longest common subsequence of `a` and `b`, two orders of the same distinct keys.
|
|
411
|
+
* With distinct keys that is a longest increasing run of `b`'s positions read in `a`'s order,
|
|
412
|
+
* found in O(n log n) by patience sorting, so a list of thousands of items stays quick.
|
|
413
|
+
*/
|
|
414
|
+
const longestCommonRun = (a, b) => {
|
|
415
|
+
const position = new Map(b.map((key, i) => [key, i]));
|
|
416
|
+
const seq = a.map((key) => position.get(key) ?? -1);
|
|
417
|
+
// tails[n]: the index in `seq` of the smallest last value of an increasing run of length n+1.
|
|
418
|
+
const tails = [];
|
|
419
|
+
const previous = new Array(seq.length).fill(-1);
|
|
420
|
+
for (let i = 0; i < seq.length; i++) {
|
|
421
|
+
let lo = 0;
|
|
422
|
+
let hi = tails.length;
|
|
423
|
+
while (lo < hi) {
|
|
424
|
+
const mid = (lo + hi) >> 1;
|
|
425
|
+
if (seq[tails[mid]] < seq[i])
|
|
426
|
+
lo = mid + 1;
|
|
427
|
+
else
|
|
428
|
+
hi = mid;
|
|
429
|
+
}
|
|
430
|
+
if (lo > 0)
|
|
431
|
+
previous[i] = tails[lo - 1];
|
|
432
|
+
tails[lo] = i;
|
|
433
|
+
}
|
|
434
|
+
const out = new Set();
|
|
435
|
+
for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
|
|
436
|
+
out.add(a[i]);
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
};
|
|
440
|
+
const WHITESPACE = /^\s+$/;
|
|
441
|
+
/**
|
|
442
|
+
* jsdiff's changes as parts. A gap of whitespace widened or narrowed comes back as the old gap
|
|
443
|
+
* removed and the new one added; it is kept as their common start, then only the net change.
|
|
444
|
+
*/
|
|
445
|
+
const toParts = (changes) => {
|
|
446
|
+
const parts = [];
|
|
447
|
+
for (let i = 0; i < changes.length; i++) {
|
|
448
|
+
const c = changes[i];
|
|
449
|
+
const next = changes[i + 1];
|
|
450
|
+
if (next &&
|
|
451
|
+
c.removed !== next.removed &&
|
|
452
|
+
(c.removed || c.added) &&
|
|
453
|
+
(next.removed || next.added) &&
|
|
454
|
+
WHITESPACE.test(c.value) &&
|
|
455
|
+
WHITESPACE.test(next.value)) {
|
|
456
|
+
const [removed, added] = c.removed ? [c.value, next.value] : [next.value, c.value];
|
|
457
|
+
const shorter = removed.length < added.length ? removed : added;
|
|
458
|
+
const longer = shorter === removed ? added : removed;
|
|
459
|
+
if (longer.startsWith(shorter)) {
|
|
460
|
+
parts.push({ value: shorter });
|
|
461
|
+
const rest = longer.slice(shorter.length);
|
|
462
|
+
parts.push(shorter === removed ? { value: rest, added: true } : { value: rest, removed: true });
|
|
463
|
+
i++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
parts.push({
|
|
468
|
+
value: c.value,
|
|
469
|
+
...(c.added && { added: true }),
|
|
470
|
+
...(c.removed && { removed: true }),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return parts;
|
|
474
|
+
};
|
|
475
|
+
/** A side of a changed row that kept no text: a blank line, marked, so the row says so. */
|
|
476
|
+
const sideParts = (parts, mark) => parts.some((p) => p.value !== '')
|
|
477
|
+
? parts
|
|
478
|
+
: [mark === 'removed' ? { value: '', removed: true } : { value: '', added: true }];
|
|
479
|
+
const splitLines = (value) => {
|
|
480
|
+
const lines = value.split('\n');
|
|
481
|
+
if (lines[lines.length - 1] === '')
|
|
482
|
+
lines.pop();
|
|
483
|
+
return lines;
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* The difference between two texts. Short single-line text is diffed by word; anything else by
|
|
487
|
+
* line, a removed line paired with the added line in its place diffed by word inside, and runs
|
|
488
|
+
* of unchanged lines cut to {@link CONTEXT_LINES} beside each change. Words are diffed with
|
|
489
|
+
* whitespace as tokens (`diffWordsWithSpace`): `diffWords` gives an unchanged run the new
|
|
490
|
+
* text's spacing, so one side read back from its parts would not be the text it was.
|
|
491
|
+
* Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
|
|
492
|
+
* diff runs until the later `wordDeadline`.
|
|
493
|
+
* @internal Exported for tests.
|
|
494
|
+
*/
|
|
495
|
+
export function textDiff(before, after, long = false, deadline = Date.now() + DIFF_TIMEOUT_MS, wordDeadline = Date.now() + SHORT_DIFF_TIMEOUT_MS) {
|
|
496
|
+
// jsdiff restarts its clock on each call, so each is given only what is left of the budget.
|
|
497
|
+
const timeLeft = () => {
|
|
498
|
+
const left = deadline - Date.now();
|
|
499
|
+
return left > 0 ? { timeout: left } : undefined;
|
|
500
|
+
};
|
|
501
|
+
const withoutEnd = (text) => (text.endsWith('\n') ? text.slice(0, -1) : text);
|
|
502
|
+
if (before !== after && withoutEnd(before) === withoutEnd(after)) {
|
|
503
|
+
return { mode: 'endLineBreak', added: after.endsWith('\n') };
|
|
504
|
+
}
|
|
505
|
+
const short = !long &&
|
|
506
|
+
!before.includes('\n') &&
|
|
507
|
+
!after.includes('\n') &&
|
|
508
|
+
before.length <= SHORT_TEXT &&
|
|
509
|
+
after.length <= SHORT_TEXT;
|
|
510
|
+
if (short) {
|
|
511
|
+
return Date.now() < wordDeadline
|
|
512
|
+
? { mode: 'words', parts: toParts(diffWordsWithSpace(before, after)) }
|
|
513
|
+
: undefined;
|
|
514
|
+
}
|
|
515
|
+
const rows = [];
|
|
516
|
+
const budget = timeLeft();
|
|
517
|
+
const changes = budget && diffLines(before, after, budget);
|
|
518
|
+
if (!changes)
|
|
519
|
+
return undefined;
|
|
520
|
+
for (let i = 0; i < changes.length; i++) {
|
|
521
|
+
const change = changes[i];
|
|
522
|
+
if (!change.added && !change.removed) {
|
|
523
|
+
for (const text of splitLines(change.value))
|
|
524
|
+
rows.push({ kind: 'same', text });
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const removed = change.removed ? splitLines(change.value) : [];
|
|
528
|
+
const next = changes[i + 1];
|
|
529
|
+
const added = change.removed && next?.added
|
|
530
|
+
? splitLines(next.value)
|
|
531
|
+
: change.added
|
|
532
|
+
? splitLines(change.value)
|
|
533
|
+
: [];
|
|
534
|
+
if (change.removed && next?.added)
|
|
535
|
+
i++;
|
|
536
|
+
const paired = Math.min(removed.length, added.length);
|
|
537
|
+
for (let n = 0; n < paired; n++) {
|
|
538
|
+
const pairBudget = timeLeft();
|
|
539
|
+
const changed = pairBudget && diffWordsWithSpace(removed[n], added[n], pairBudget);
|
|
540
|
+
if (!changed)
|
|
541
|
+
return undefined;
|
|
542
|
+
const words = toParts(changed);
|
|
543
|
+
rows.push({
|
|
544
|
+
kind: 'change',
|
|
545
|
+
before: sideParts(words.filter((p) => !p.added), 'removed'),
|
|
546
|
+
after: sideParts(words.filter((p) => !p.removed), 'added'),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
for (const text of removed.slice(paired))
|
|
550
|
+
rows.push({ kind: 'change', before: [{ value: text, removed: true }] });
|
|
551
|
+
for (const text of added.slice(paired))
|
|
552
|
+
rows.push({ kind: 'change', after: [{ value: text, added: true }] });
|
|
553
|
+
}
|
|
554
|
+
return { mode: 'lines', rows: withContext(rows) };
|
|
555
|
+
}
|
|
556
|
+
/** `lines` with each run of unchanged lines cut to the context beside its changes. */
|
|
557
|
+
const withContext = (lines) => {
|
|
558
|
+
const out = [];
|
|
559
|
+
for (let i = 0; i < lines.length;) {
|
|
560
|
+
if (lines[i].kind !== 'same') {
|
|
561
|
+
out.push(lines[i++]);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
let end = i;
|
|
565
|
+
while (end < lines.length && lines[end].kind === 'same')
|
|
566
|
+
end++;
|
|
567
|
+
const keepBefore = i === 0 ? 0 : CONTEXT_LINES;
|
|
568
|
+
const keepAfter = end === lines.length ? 0 : CONTEXT_LINES;
|
|
569
|
+
if (end - i <= keepBefore + keepAfter + 1) {
|
|
570
|
+
out.push(...lines.slice(i, end));
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
out.push(...lines.slice(i, i + keepBefore));
|
|
574
|
+
out.push({ kind: 'skipped', count: end - i - keepBefore - keepAfter });
|
|
575
|
+
out.push(...lines.slice(end - keepAfter, end));
|
|
576
|
+
}
|
|
577
|
+
i = end;
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
};
|
|
581
|
+
/** The entry's changes between `before` and `after`, in schema order. */
|
|
582
|
+
export function computeEntryDiff({ fields, before, after, referenceTitle, }) {
|
|
583
|
+
// One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS.
|
|
584
|
+
const started = Date.now();
|
|
585
|
+
const walker = new Walker(referenceTitle, started + DIFF_TIMEOUT_MS, started + SHORT_DIFF_TIMEOUT_MS);
|
|
586
|
+
const root = { path: [], key: '' };
|
|
587
|
+
if (before && after && before.entryType !== after.entryType) {
|
|
588
|
+
walker.value(undefined, humanizeName(before.entryType), humanizeName(after.entryType), at(root, 'Entry type', '$entryType'));
|
|
589
|
+
}
|
|
590
|
+
walker.fields(fields, formValue(fields, before), formValue(fields, after), root);
|
|
591
|
+
const changes = walker.changes;
|
|
592
|
+
return { changes, formattingOnly: !!before && !!after && changes.length === 0 };
|
|
593
|
+
}
|
|
@@ -8,12 +8,22 @@ import { type CanopyApiClient } from '../../api/client.js';
|
|
|
8
8
|
export declare const CREATE_BRANCH_DEADLINE_MS: number;
|
|
9
9
|
/** @internal Exported for tests. */
|
|
10
10
|
export declare const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
|
|
11
|
+
/** The typed name cannot be created: the create form shows `refusal` in place, with no toast. */
|
|
12
|
+
export interface CreateRefusal {
|
|
13
|
+
refusal: string;
|
|
14
|
+
}
|
|
11
15
|
export type CreateBranchOutcome = {
|
|
12
16
|
kind: 'created';
|
|
13
17
|
branch: BranchListItem | undefined;
|
|
14
|
-
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `refused`: the server refused this name (`BRANCH_NAME_REFUSED`), or the listing a busy or
|
|
21
|
+
* unanswered create settles on holds another user's branch of it. Retrying it cannot pass.
|
|
22
|
+
*/
|
|
23
|
+
| {
|
|
15
24
|
kind: 'failed';
|
|
16
25
|
message: string;
|
|
26
|
+
refused?: true;
|
|
17
27
|
};
|
|
18
28
|
/**
|
|
19
29
|
* Create a branch. Past the deadline, after an error page from in front of the API, or after a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isCreatorsRecentBranch } from '../../api/branch-create-window.js';
|
|
2
2
|
import { isApiTimeoutError, isNonApiResponse } from '../../api/client.js';
|
|
3
3
|
import { GATEWAY_MAX_TIMEOUT_MS } from '../../api/request-deadline.js';
|
|
4
|
-
import {
|
|
4
|
+
import { parseCreatedBranchName } from '../../paths/validation.js';
|
|
5
5
|
/**
|
|
6
6
|
* @internal Exported for tests. Past the gateway's limit, so its 504 lands first, and under
|
|
7
7
|
* `REQUEST_TIMEOUT_MS.long`. The limit comes from `request-deadline` directly: a test that mocks
|
|
@@ -32,8 +32,11 @@ export async function requestBranchCreate(apiClient, body, userId, deadlineMs =
|
|
|
32
32
|
if (isNonApiResponse(result))
|
|
33
33
|
return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
|
|
34
34
|
const message = result.error || 'Failed to create branch';
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
if (result.status === 503)
|
|
36
|
+
return settle(apiClient, body.branch, userId, message);
|
|
37
|
+
// Only a refusal of the name carries this code; any other failure is the toast's to report.
|
|
38
|
+
return result.code === 'BRANCH_NAME_REFUSED'
|
|
39
|
+
? { kind: 'failed', message, refused: true }
|
|
37
40
|
: { kind: 'failed', message };
|
|
38
41
|
}
|
|
39
42
|
/** A same-named branch the server would not answer this create with is a name conflict. */
|
|
@@ -43,15 +46,22 @@ async function settle(apiClient, requested, userId, message) {
|
|
|
43
46
|
return { kind: 'failed', message };
|
|
44
47
|
if (isCreatorsRecentBranch(listed, userId))
|
|
45
48
|
return { kind: 'created', branch: listed };
|
|
46
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
kind: 'failed',
|
|
51
|
+
message: `A branch named “${listed.shortName ?? listed.name}” already exists`,
|
|
52
|
+
refused: true,
|
|
53
|
+
};
|
|
47
54
|
}
|
|
55
|
+
/** The listed namespace branch (short name not its whole name) that `requested` creates. */
|
|
48
56
|
async function findListedBranch(apiClient, requested) {
|
|
49
|
-
const
|
|
57
|
+
const created = parseCreatedBranchName(requested);
|
|
58
|
+
if (!created.ok)
|
|
59
|
+
return undefined;
|
|
50
60
|
try {
|
|
51
61
|
const listing = await apiClient.branches.list();
|
|
52
62
|
if (!listing.ok)
|
|
53
63
|
return undefined;
|
|
54
|
-
return listing.data?.branches.find((branch) =>
|
|
64
|
+
return listing.data?.branches.find((branch) => branch.shortName !== branch.name && branch.shortName === created.shortName);
|
|
55
65
|
}
|
|
56
66
|
catch {
|
|
57
67
|
return undefined;
|