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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cards "Show changes" shows for an entry's open conflicts, built from what
|
|
3
|
+
* `GET /:branch/conflicts/...entryPath` answers (api/merge-conflicts.ts): each place's three
|
|
4
|
+
* versions as the drawer shows values, and long text split into the regions both sides changed.
|
|
5
|
+
* Pure. Where a choice lands in the form value is `conflict-choices.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import equal from 'fast-deep-equal';
|
|
8
|
+
import { LOCATION_CONFLICT_KEY } from '../merge/conflict-path.js';
|
|
9
|
+
import { mergeText } from '../merge/text-merge.js';
|
|
10
|
+
import { computeEntryDiff, displayWhole, textDiff, } from './entry-diff.js';
|
|
11
|
+
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
12
|
+
import { humanizeName, schemaLabel } from './schema-label.js';
|
|
13
|
+
import { formValueOfSide, valueAtChange } from './undo-change.js';
|
|
14
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
15
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
16
|
+
const EMPTY = { kind: 'empty' };
|
|
17
|
+
/** The version a blob id names; undefined when it can't be shown. */
|
|
18
|
+
const versionOf = (versions, sha) => {
|
|
19
|
+
if (sha === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
const side = versions[sha];
|
|
22
|
+
return side === undefined || 'unavailable' in side ? undefined : side;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* `version` with its list items along `from`, its own path to a conflict's place, keyed as `to`,
|
|
26
|
+
* the merged path, names them, so `to` finds the value in it: an item of a list a side left keyless
|
|
27
|
+
* reads there with a key of its own content (api/merge-conflicts.ts `EntryConflictField.sidePaths`).
|
|
28
|
+
* Unchanged where there is no `from` or it is `to`. Undefined, as a version that can't be shown,
|
|
29
|
+
* where the two paths don't run alike or a key `to` names is another item's in that list: the
|
|
30
|
+
* merged path would find that item there.
|
|
31
|
+
*/
|
|
32
|
+
const alignedTo = (version, from, to) => {
|
|
33
|
+
if (!version || !from || equal(from, to))
|
|
34
|
+
return version;
|
|
35
|
+
// A path one step short of an item names the list of a version without that item.
|
|
36
|
+
const last = to.at(-1);
|
|
37
|
+
const missing = from.length === to.length - 1 && last !== undefined && 'item' in last ? last.item : undefined;
|
|
38
|
+
if (missing === undefined && from.length !== to.length)
|
|
39
|
+
return undefined;
|
|
40
|
+
const data = structuredClone(version.data);
|
|
41
|
+
let node = data;
|
|
42
|
+
for (const [i, segment] of from.entries()) {
|
|
43
|
+
const target = to[i];
|
|
44
|
+
if ('field' in segment) {
|
|
45
|
+
if (!('field' in target) || target.field !== segment.field)
|
|
46
|
+
return undefined;
|
|
47
|
+
node = isPlainRecord(node) ? node[segment.field] : undefined;
|
|
48
|
+
}
|
|
49
|
+
else if ('blockData' in segment) {
|
|
50
|
+
if (!('blockData' in target))
|
|
51
|
+
return undefined;
|
|
52
|
+
node = isPlainRecord(node) ? (isPlainRecord(node.value) ? node.value : node) : undefined;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (!('item' in target) || !Array.isArray(node))
|
|
56
|
+
return undefined;
|
|
57
|
+
const keyed = (key) => node.find((x) => isPlainRecord(x) && x[ITEM_KEY] === key);
|
|
58
|
+
const item = keyed(segment.item);
|
|
59
|
+
if (!isPlainRecord(item))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (target.item !== segment.item) {
|
|
62
|
+
if (keyed(target.item) !== undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
item[ITEM_KEY] = target.item;
|
|
65
|
+
}
|
|
66
|
+
node = item;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (missing !== undefined) {
|
|
70
|
+
// Another item there holding the key `to` names would be found as it: it takes another key.
|
|
71
|
+
if (!Array.isArray(node))
|
|
72
|
+
return undefined;
|
|
73
|
+
const other = node.find((x) => isPlainRecord(x) && x[ITEM_KEY] === missing);
|
|
74
|
+
if (isPlainRecord(other))
|
|
75
|
+
other[ITEM_KEY] = `${missing}~other`;
|
|
76
|
+
}
|
|
77
|
+
return { ...version, data };
|
|
78
|
+
};
|
|
79
|
+
/** A diff's changes by key, a whole entry missing on a side giving none. */
|
|
80
|
+
const changesByKey = (fields, before, after, referenceTitle) => {
|
|
81
|
+
if (!before || !after)
|
|
82
|
+
return new Map();
|
|
83
|
+
const { changes } = computeEntryDiff({ fields, before, after, referenceTitle });
|
|
84
|
+
return new Map(changes.map((change) => [change.key, change]));
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* The schema field a data path ends at, and the labels of the fields on the way, for a conflict no
|
|
88
|
+
* change of the drawer names: one the merge compared whole, such as a list it had no schema for.
|
|
89
|
+
* A block's data has no one field set, so the walk stops there.
|
|
90
|
+
*/
|
|
91
|
+
const fieldAlong = (fields, dataPath) => {
|
|
92
|
+
let scope = fields;
|
|
93
|
+
let field;
|
|
94
|
+
const path = [];
|
|
95
|
+
for (const segment of dataPath) {
|
|
96
|
+
if ('field' in segment) {
|
|
97
|
+
field = scope && flattenGroupFields(scope).find((f) => f.name === segment.field);
|
|
98
|
+
path.push(field ? schemaLabel(field) : humanizeName(segment.field));
|
|
99
|
+
scope = field?.type === 'object' ? field.fields : undefined;
|
|
100
|
+
}
|
|
101
|
+
else if ('item' in segment) {
|
|
102
|
+
field = field && 'list' in field && field.list ? { ...field, list: false } : undefined;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
return { path };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { field, path };
|
|
109
|
+
};
|
|
110
|
+
/** A string at `dataPath` in a version, as the form holds it. */
|
|
111
|
+
const textAt = (version, dataPath, bodyField) => {
|
|
112
|
+
if (!version)
|
|
113
|
+
return version === null ? '' : undefined;
|
|
114
|
+
const value = valueAtChange(formValueOfSide(version), { dataPath }, bodyField);
|
|
115
|
+
return typeof value === 'string' ? value : value === undefined ? '' : undefined;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* The cards for one entry's open field conflicts. `bodyField` is the schema's body field on an
|
|
119
|
+
* md/mdx entry. Each version the cards compare is diffed once per pair, however many fields share it.
|
|
120
|
+
*/
|
|
121
|
+
export function buildEntryConflicts(data, fields, bodyField, referenceTitle, saved) {
|
|
122
|
+
const diffs = new Map();
|
|
123
|
+
const diff = (from, to) => {
|
|
124
|
+
const id = `${from ?? '-'}>${to ?? '-'}`;
|
|
125
|
+
let found = diffs.get(id);
|
|
126
|
+
if (!found) {
|
|
127
|
+
found = changesByKey(fields, versionOf(data.versions, from), versionOf(data.versions, to), referenceTitle);
|
|
128
|
+
diffs.set(id, found);
|
|
129
|
+
}
|
|
130
|
+
return found;
|
|
131
|
+
};
|
|
132
|
+
let incomplete = false;
|
|
133
|
+
const out = data.fields.map((field) => {
|
|
134
|
+
const { ancestor, base, branch } = field.sides;
|
|
135
|
+
const read = {
|
|
136
|
+
before: versionOf(data.versions, ancestor),
|
|
137
|
+
latest: versionOf(data.versions, base),
|
|
138
|
+
yours: versionOf(data.versions, branch),
|
|
139
|
+
};
|
|
140
|
+
if (Object.values(read).some((v) => v === undefined))
|
|
141
|
+
incomplete = true;
|
|
142
|
+
// Each version keyed as the merge named this place, so the one data path finds it in all three.
|
|
143
|
+
const versions = {
|
|
144
|
+
before: alignedTo(read.before, field.sidePaths.ancestor, field.dataPath),
|
|
145
|
+
latest: alignedTo(read.latest, field.sidePaths.base, field.dataPath),
|
|
146
|
+
yours: alignedTo(read.yours, field.sidePaths.branch, field.dataPath),
|
|
147
|
+
};
|
|
148
|
+
const realigned = ['before', 'latest', 'yours'].some((v) => versions[v] !== read[v]);
|
|
149
|
+
if (Object.values(versions).some((v) => v === undefined))
|
|
150
|
+
incomplete = true;
|
|
151
|
+
const latest = versions.latest ? formValueOfSide(versions.latest) : undefined;
|
|
152
|
+
// Your saved value where a save since the merge changed it, which is what keeping yours keeps:
|
|
153
|
+
// the branch's version is only how it read at the merge.
|
|
154
|
+
const savedHere = saved && valueAtChange(saved, { dataPath: field.dataPath }, bodyField);
|
|
155
|
+
const branchHere = versions.yours &&
|
|
156
|
+
valueAtChange(formValueOfSide(versions.yours), { dataPath: field.dataPath }, bodyField);
|
|
157
|
+
// A save can give a look-alike the key of an item your version lacks once it is gone: a choice
|
|
158
|
+
// here would land on that other item.
|
|
159
|
+
const otherItemHere = field.item && !!versions.yours && branchHere === undefined && savedHere !== undefined;
|
|
160
|
+
const latestFound = !!latest &&
|
|
161
|
+
field.dataPath.every((segment, i) => !('item' in segment) ||
|
|
162
|
+
(field.item && field.kind === 'removed-on-base' && i === field.dataPath.length - 1) ||
|
|
163
|
+
valueAtChange(latest, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !==
|
|
164
|
+
undefined);
|
|
165
|
+
const latestPlaceable = latestFound && !otherItemHere;
|
|
166
|
+
const common = {
|
|
167
|
+
key: field.key,
|
|
168
|
+
mergeSha: field.mergeSha,
|
|
169
|
+
dataPath: field.dataPath,
|
|
170
|
+
item: !!field.item,
|
|
171
|
+
latestPlaceable,
|
|
172
|
+
...(otherItemHere && { otherItemHere: true }),
|
|
173
|
+
...(latest && { latest }),
|
|
174
|
+
};
|
|
175
|
+
const card = { key: field.key, mergeSha: field.mergeSha };
|
|
176
|
+
if (field.key === LOCATION_CONFLICT_KEY) {
|
|
177
|
+
return { ...common, cards: [{ ...card, id: field.key, type: 'location', path: [] }] };
|
|
178
|
+
}
|
|
179
|
+
// A realigned version is this field's own, so its diffs are too.
|
|
180
|
+
const pair = (from, to, fromSha, toSha) => (realigned ? changesByKey(fields, from, to, referenceTitle) : diff(fromSha, toSha));
|
|
181
|
+
const toLatest = pair(versions.before, versions.latest, ancestor, base);
|
|
182
|
+
const toYours = pair(versions.before, versions.yours, ancestor, branch);
|
|
183
|
+
const between = pair(versions.latest, versions.yours, base, branch);
|
|
184
|
+
const named = toLatest.get(field.drawerKey) ?? toYours.get(field.drawerKey);
|
|
185
|
+
const along = fieldAlong(fields, field.dataPath);
|
|
186
|
+
const path = named?.path ?? between.get(field.drawerKey)?.path ?? along.path;
|
|
187
|
+
const savedYours = saved && !otherItemHere && !equal(savedHere, branchHere)
|
|
188
|
+
? displayWhole(along.field, savedHere, referenceTitle)
|
|
189
|
+
: undefined;
|
|
190
|
+
const savedMarks = (before) => savedYours && before.kind === 'text' && savedYours.kind === 'text'
|
|
191
|
+
? textDiff(before.text, savedYours.text)
|
|
192
|
+
: undefined;
|
|
193
|
+
if (field.text) {
|
|
194
|
+
const texts = [versions.before, versions.latest, versions.yours].map((v) => textAt(v, field.dataPath, bodyField));
|
|
195
|
+
const [o, a, b] = texts;
|
|
196
|
+
const merged = latestPlaceable && o !== undefined && a !== undefined && b !== undefined
|
|
197
|
+
? mergeText(o, a, b)
|
|
198
|
+
: undefined;
|
|
199
|
+
// Regions place their lines in the text the merge left, so they apply only while your saved
|
|
200
|
+
// text is that text: not once you saved since, nor where the merge kept your text whole.
|
|
201
|
+
const regionsApply = !!merged &&
|
|
202
|
+
merged.regions.length > 0 &&
|
|
203
|
+
(saved === undefined ||
|
|
204
|
+
valueAtChange(saved, { dataPath: field.dataPath }, bodyField) === merged.text);
|
|
205
|
+
if (merged && regionsApply) {
|
|
206
|
+
return {
|
|
207
|
+
...common,
|
|
208
|
+
text: { merged: merged.text, regions: merged.regions },
|
|
209
|
+
cards: merged.regions.map((region, index) => ({
|
|
210
|
+
...card,
|
|
211
|
+
id: `${field.key}#${index}`,
|
|
212
|
+
type: 'region',
|
|
213
|
+
path,
|
|
214
|
+
index,
|
|
215
|
+
count: merged.regions.length,
|
|
216
|
+
before: region.ancestor,
|
|
217
|
+
latest: region.base,
|
|
218
|
+
yours: region.branch,
|
|
219
|
+
latestText: textDiff(region.ancestor, region.base, true),
|
|
220
|
+
yoursText: textDiff(region.ancestor, region.branch, true),
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const fromLatest = toLatest.get(field.drawerKey);
|
|
226
|
+
const fromYours = toYours.get(field.drawerKey);
|
|
227
|
+
const across = between.get(field.drawerKey);
|
|
228
|
+
if (!fromLatest && !fromYours && !across) {
|
|
229
|
+
// No change of the drawer is at this place: the merge compared it whole, so it shows whole.
|
|
230
|
+
const whole = (version) => version
|
|
231
|
+
? displayWhole(along.field, valueAtChange(formValueOfSide(version), { dataPath: field.dataPath }, bodyField), referenceTitle)
|
|
232
|
+
: EMPTY;
|
|
233
|
+
const [before, latestValue, yoursValue] = [
|
|
234
|
+
versions.before,
|
|
235
|
+
versions.latest,
|
|
236
|
+
versions.yours,
|
|
237
|
+
].map(whole);
|
|
238
|
+
const marks = (to) => before.kind === 'text' && to.kind === 'text' ? textDiff(before.text, to.text) : undefined;
|
|
239
|
+
const latestText = marks(latestValue);
|
|
240
|
+
const yoursText = savedYours ? savedMarks(before) : marks(yoursValue);
|
|
241
|
+
return {
|
|
242
|
+
...common,
|
|
243
|
+
cards: [
|
|
244
|
+
{
|
|
245
|
+
...card,
|
|
246
|
+
id: field.key,
|
|
247
|
+
type: 'value',
|
|
248
|
+
conflict: field.kind,
|
|
249
|
+
item: !!field.item,
|
|
250
|
+
path,
|
|
251
|
+
before,
|
|
252
|
+
latest: latestValue,
|
|
253
|
+
...(!latestFound && { latestUnavailable: true }),
|
|
254
|
+
yours: savedYours ?? yoursValue,
|
|
255
|
+
...(latestText && { latestText }),
|
|
256
|
+
...(yoursText && { yoursText }),
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const before = fromLatest?.before ?? fromYours?.before ?? EMPTY;
|
|
262
|
+
const yoursText = savedYours ? savedMarks(before) : fromYours?.text;
|
|
263
|
+
return {
|
|
264
|
+
...common,
|
|
265
|
+
cards: [
|
|
266
|
+
{
|
|
267
|
+
...card,
|
|
268
|
+
id: field.key,
|
|
269
|
+
type: 'value',
|
|
270
|
+
conflict: field.kind,
|
|
271
|
+
item: !!field.item,
|
|
272
|
+
path,
|
|
273
|
+
before,
|
|
274
|
+
latest: fromLatest ? (fromLatest.after ?? EMPTY) : (across?.before ?? EMPTY),
|
|
275
|
+
...(!latestFound && { latestUnavailable: true }),
|
|
276
|
+
yours: savedYours ?? (fromYours ? (fromYours.after ?? EMPTY) : (across?.after ?? EMPTY)),
|
|
277
|
+
...(fromLatest?.text && { latestText: fromLatest.text }),
|
|
278
|
+
...(yoursText && { yoursText }),
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
return { fields: out, incomplete };
|
|
284
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a choice on a conflict card does to the open entry's form value (conflict-cards.ts builds
|
|
3
|
+
* the cards). Pure. A choice is an unsaved edit: the save that writes it sends the conflict's key in
|
|
4
|
+
* `resolvedConflicts` (api/content.ts), which is what resolves it.
|
|
5
|
+
*/
|
|
6
|
+
import type { TextConflictRegion } from '../merge/text-merge.js';
|
|
7
|
+
import type { FieldConflict } from './conflict-cards.js';
|
|
8
|
+
import type { EntryChange } from './entry-diff.js';
|
|
9
|
+
type FormValue = Record<string, unknown>;
|
|
10
|
+
/** A region's choice: the latest version's lines, yours, or the latest's followed by yours. */
|
|
11
|
+
export type RegionChoice = 'latest' | 'yours' | 'both';
|
|
12
|
+
/**
|
|
13
|
+
* `latest`: the latest version's value, which for a removed one removes it. `yours`: your saved
|
|
14
|
+
* value as it stands. `edited`: what the form holds now, marked resolved after an edit. `regions`:
|
|
15
|
+
* long text chosen region by region, resolved once every region is chosen.
|
|
16
|
+
*/
|
|
17
|
+
export type FieldChoice = {
|
|
18
|
+
how: 'latest';
|
|
19
|
+
} | {
|
|
20
|
+
how: 'yours';
|
|
21
|
+
} | {
|
|
22
|
+
how: 'edited';
|
|
23
|
+
} | {
|
|
24
|
+
how: 'regions';
|
|
25
|
+
regions: Readonly<Record<number, RegionChoice>>;
|
|
26
|
+
};
|
|
27
|
+
/** An entry's choices, by conflict key, each for the merge it was made on. */
|
|
28
|
+
export type EntryChoices = Readonly<Record<string, {
|
|
29
|
+
mergeSha: string;
|
|
30
|
+
choice: FieldChoice;
|
|
31
|
+
}>>;
|
|
32
|
+
/** The change-shaped handle `undo-change.ts` places a value by. */
|
|
33
|
+
export declare const placeOf: (field: Pick<FieldConflict, "key" | "dataPath" | "item">) => EntryChange;
|
|
34
|
+
/** Whether a choice says everything its conflict needs. */
|
|
35
|
+
export declare const isDecided: (field: FieldConflict, choice: FieldChoice | undefined) => boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The text the merge left with each region as `regions` chooses it. A region's lines sit where the
|
|
38
|
+
* merge put yours (`TextConflictRegion.line`), so regions are placed from the last up.
|
|
39
|
+
* @internal Exported for tests.
|
|
40
|
+
*/
|
|
41
|
+
export declare function textWithChoices(text: {
|
|
42
|
+
merged: string;
|
|
43
|
+
regions: readonly TextConflictRegion[];
|
|
44
|
+
}, regions: Readonly<Record<number, RegionChoice>>): string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the form still holds the conflict's place as the last choice (or, before any, your saved
|
|
47
|
+
* value) left it. False once it was edited by hand: a choice would then replace that edit.
|
|
48
|
+
*/
|
|
49
|
+
export declare function holdsChoice(current: FormValue, saved: FormValue, field: FieldConflict, choice: FieldChoice | undefined, bodyField: string | undefined): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* `current` with the latest version at the conflict's place; undefined when it can't be placed, as
|
|
52
|
+
* where the latest version couldn't be read or doesn't hold the items on the place's path
|
|
53
|
+
* ({@link FieldConflict.latestPlaceable}).
|
|
54
|
+
*/
|
|
55
|
+
export declare const withLatest: (current: FormValue, field: FieldConflict, bodyField: string | undefined) => FormValue | undefined;
|
|
56
|
+
/** `current` with your saved value back at the conflict's place, as before any choice. */
|
|
57
|
+
export declare const withYours: (current: FormValue, saved: FormValue, field: FieldConflict, bodyField: string | undefined) => FormValue | undefined;
|
|
58
|
+
/** `current` with long text as `regions` chooses it. */
|
|
59
|
+
export declare const withRegions: (current: FormValue, field: FieldConflict, regions: Readonly<Record<number, RegionChoice>>, bodyField: string | undefined) => FormValue | undefined;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a choice on a conflict card does to the open entry's form value (conflict-cards.ts builds
|
|
3
|
+
* the cards). Pure. A choice is an unsaved edit: the save that writes it sends the conflict's key in
|
|
4
|
+
* `resolvedConflicts` (api/content.ts), which is what resolves it.
|
|
5
|
+
*/
|
|
6
|
+
import { holdsAs, setChangeBack, valueAtChange, withValueAt } from './undo-change.js';
|
|
7
|
+
/** The change-shaped handle `undo-change.ts` places a value by. */
|
|
8
|
+
export const placeOf = (field) => ({
|
|
9
|
+
key: field.key,
|
|
10
|
+
dataPath: field.dataPath,
|
|
11
|
+
path: [],
|
|
12
|
+
kind: 'changed',
|
|
13
|
+
...(field.item && { item: true }),
|
|
14
|
+
});
|
|
15
|
+
/** Whether a choice says everything its conflict needs. */
|
|
16
|
+
export const isDecided = (field, choice) => !!choice &&
|
|
17
|
+
(choice.how !== 'regions' ||
|
|
18
|
+
(!!field.text && field.text.regions.every((_, i) => choice.regions[i] !== undefined)));
|
|
19
|
+
const linesOf = (text, count) => count === 0 || (count === undefined && text === '') ? [] : text.split('\n');
|
|
20
|
+
/** A region's lines under `choice`; undecided, they are yours, as the merge left them. */
|
|
21
|
+
const regionLines = (region, choice) => {
|
|
22
|
+
const yours = linesOf(region.branch, region.lines);
|
|
23
|
+
// Counted, as the branch's are: joined, one blank line reads as none.
|
|
24
|
+
const latest = linesOf(region.base, region.baseLines);
|
|
25
|
+
if (choice === 'latest')
|
|
26
|
+
return latest;
|
|
27
|
+
if (choice === 'both')
|
|
28
|
+
return [...latest, ...yours];
|
|
29
|
+
return yours;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The text the merge left with each region as `regions` chooses it. A region's lines sit where the
|
|
33
|
+
* merge put yours (`TextConflictRegion.line`), so regions are placed from the last up.
|
|
34
|
+
* @internal Exported for tests.
|
|
35
|
+
*/
|
|
36
|
+
export function textWithChoices(text, regions) {
|
|
37
|
+
const endBreak = text.merged.endsWith('\n');
|
|
38
|
+
const body = endBreak ? text.merged.slice(0, -1) : text.merged;
|
|
39
|
+
const lines = body === '' && !endBreak ? [] : body.split('\n');
|
|
40
|
+
for (let i = text.regions.length - 1; i >= 0; i--) {
|
|
41
|
+
const region = text.regions[i];
|
|
42
|
+
lines.splice(region.line, region.lines, ...regionLines(region, regions[i]));
|
|
43
|
+
}
|
|
44
|
+
const joined = lines.join('\n');
|
|
45
|
+
return endBreak ? `${joined}\n` : joined;
|
|
46
|
+
}
|
|
47
|
+
/** What the form would hold at the conflict's place under `choice`, given your saved value. */
|
|
48
|
+
const expectedUnder = (field, choice, saved) => {
|
|
49
|
+
if (!choice || choice.how === 'yours')
|
|
50
|
+
return { from: saved };
|
|
51
|
+
if (choice.how === 'latest')
|
|
52
|
+
return field.latest ? { from: field.latest } : undefined;
|
|
53
|
+
if (choice.how === 'regions' && field.text)
|
|
54
|
+
return { text: textWithChoices(field.text, choice.regions) };
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Whether the form still holds the conflict's place as the last choice (or, before any, your saved
|
|
59
|
+
* value) left it. False once it was edited by hand: a choice would then replace that edit.
|
|
60
|
+
*/
|
|
61
|
+
export function holdsChoice(current, saved, field, choice, bodyField) {
|
|
62
|
+
const expected = expectedUnder(field, choice, saved);
|
|
63
|
+
// A place with no path, such as where the entry is, holds nothing the form could edit.
|
|
64
|
+
if (!expected || field.dataPath.length === 0)
|
|
65
|
+
return true;
|
|
66
|
+
if ('text' in expected)
|
|
67
|
+
return valueAtChange(current, field, bodyField) === expected.text;
|
|
68
|
+
// An item on the way that the form no longer has holds nothing, though an empty value would
|
|
69
|
+
// compare equal to the nothing found there.
|
|
70
|
+
const from = expected.from;
|
|
71
|
+
const itemGone = field.dataPath.some((segment, i) => 'item' in segment &&
|
|
72
|
+
valueAtChange(from, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !== undefined &&
|
|
73
|
+
valueAtChange(current, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) === undefined);
|
|
74
|
+
if (itemGone)
|
|
75
|
+
return false;
|
|
76
|
+
// Long text checks the merged text, which your saved value holds until a region is chosen.
|
|
77
|
+
if (field.text && (!choice || choice.how === 'yours')) {
|
|
78
|
+
return valueAtChange(current, field, bodyField) === valueAtChange(saved, field, bodyField);
|
|
79
|
+
}
|
|
80
|
+
return holdsAs(current, expected.from, placeOf(field), bodyField);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `current` with the conflict's place as `source` has it: a list item `source` lacks taken out, or
|
|
84
|
+
* one `current` lacks put in where `source` has it, through `undo-change.ts`; anything else, an item
|
|
85
|
+
* both hold included, replaced where it is, by key. Undefined when it can't be placed.
|
|
86
|
+
*/
|
|
87
|
+
const placeFrom = (current, source, field, bodyField) => {
|
|
88
|
+
const place = placeOf(field);
|
|
89
|
+
const wanted = valueAtChange(source, place, bodyField);
|
|
90
|
+
if (wanted === undefined ||
|
|
91
|
+
(field.item && valueAtChange(current, place, bodyField) === undefined))
|
|
92
|
+
return setChangeBack(current, source, place, bodyField);
|
|
93
|
+
return withValueAt(current, place, structuredClone(wanted), bodyField);
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* `current` with the latest version at the conflict's place; undefined when it can't be placed, as
|
|
97
|
+
* where the latest version couldn't be read or doesn't hold the items on the place's path
|
|
98
|
+
* ({@link FieldConflict.latestPlaceable}).
|
|
99
|
+
*/
|
|
100
|
+
export const withLatest = (current, field, bodyField) => field.latest && field.latestPlaceable
|
|
101
|
+
? placeFrom(current, field.latest, field, bodyField)
|
|
102
|
+
: undefined;
|
|
103
|
+
/** `current` with your saved value back at the conflict's place, as before any choice. */
|
|
104
|
+
export const withYours = (current, saved, field, bodyField) => placeFrom(current, saved, field, bodyField);
|
|
105
|
+
/** `current` with long text as `regions` chooses it. */
|
|
106
|
+
export const withRegions = (current, field, regions, bodyField) => field.text
|
|
107
|
+
? withValueAt(current, field, textWithChoices(field.text, regions), bodyField)
|
|
108
|
+
: undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the editor says about a branch's open conflicts outside "Show changes": the open entry's
|
|
3
|
+
* notice, Submit's reason, the bar's badge and the "Get latest changes" toast. A count is of the
|
|
4
|
+
* choices left (`MergeConflictsData.openCount`): one per field, and one per entry-level conflict
|
|
5
|
+
* (`openConflictCount`, merge-conflicts.ts). No copy names a
|
|
6
|
+
* branch: an author may not know the branch theirs was cut from.
|
|
7
|
+
*/
|
|
8
|
+
import type { MergeConflictListItem } from '../api/merge-conflicts.js';
|
|
9
|
+
/** The notice above an entry's form while it has conflicts; the list item when it is loaded. */
|
|
10
|
+
export declare function entryConflictNotice(item: MergeConflictListItem | undefined): string;
|
|
11
|
+
/** Why Submit for review waits while choices are open. */
|
|
12
|
+
export declare const conflictsSubmitReason: (count: number) => string;
|
|
13
|
+
/** The bar's "Needs action" detail while choices are open. */
|
|
14
|
+
export declare const conflictsHoldMessage: (count: number) => string;
|
|
15
|
+
/**
|
|
16
|
+
* The "Get latest changes" toast when the merge left choices; undefined: not yet counted. None
|
|
17
|
+
* left is a plain success, which base-update-wait.ts says instead.
|
|
18
|
+
*/
|
|
19
|
+
export declare const latestChangesConflictsMessage: (count: number | undefined) => string;
|
|
20
|
+
/** The bar's "Needs action" detail while the branch's record of conflicts is held. */
|
|
21
|
+
export declare const conflictRecordHeldMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved until an admin releases the hold.";
|
|
22
|
+
/** The same, to an admin, who has the way out beside it. */
|
|
23
|
+
export declare const conflictRecordHeldAdminMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved. Use \"Release hold\" to let it go on.";
|
|
24
|
+
/** What "Release hold" gives up, in its confirm. */
|
|
25
|
+
export declare const releaseHoldConsequences = "Releasing lets this branch be submitted and approved again. The list of fields that needed a choice is lost for good: each field keeps the value it has now, and no one will be asked to choose. Check the branch's changes first.";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the editor says about a branch's open conflicts outside "Show changes": the open entry's
|
|
3
|
+
* notice, Submit's reason, the bar's badge and the "Get latest changes" toast. A count is of the
|
|
4
|
+
* choices left (`MergeConflictsData.openCount`): one per field, and one per entry-level conflict
|
|
5
|
+
* (`openConflictCount`, merge-conflicts.ts). No copy names a
|
|
6
|
+
* branch: an author may not know the branch theirs was cut from.
|
|
7
|
+
*/
|
|
8
|
+
/** "1 field needs", "2 fields need". */
|
|
9
|
+
const fieldsNeed = (count) => count === 1 ? '1 field needs' : `${count} fields need`;
|
|
10
|
+
const CHOOSE = 'Choose what to keep in "Show changes".';
|
|
11
|
+
/** The notice above an entry's form while it has conflicts; the list item when it is loaded. */
|
|
12
|
+
export function entryConflictNotice(item) {
|
|
13
|
+
switch (item?.kind) {
|
|
14
|
+
case 'deleted-on-base':
|
|
15
|
+
return `This entry was deleted in the latest version, but you changed it. ${CHOOSE}`;
|
|
16
|
+
case 'unreadable':
|
|
17
|
+
case 'deleted-on-branch':
|
|
18
|
+
return `This entry was changed by you and by someone else. ${CHOOSE}`;
|
|
19
|
+
case 'fields':
|
|
20
|
+
return `${item.fieldCount === 1 ? '1 field was' : `${item.fieldCount} fields were`} changed by you and by someone else. ${CHOOSE}`;
|
|
21
|
+
default:
|
|
22
|
+
return `Some fields were changed by you and by someone else. ${CHOOSE}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Why Submit for review waits while choices are open. */
|
|
26
|
+
export const conflictsSubmitReason = (count) => `${fieldsNeed(count)} your choice before this branch can be submitted. Choose what to keep in "Show changes" on each flagged entry.`;
|
|
27
|
+
/** The bar's "Needs action" detail while choices are open. */
|
|
28
|
+
export const conflictsHoldMessage = (count) => `${fieldsNeed(count)} your choice: changed by you and by someone else. Choose what to keep in "Show changes" on each flagged entry, then submit.`;
|
|
29
|
+
/**
|
|
30
|
+
* The "Get latest changes" toast when the merge left choices; undefined: not yet counted. None
|
|
31
|
+
* left is a plain success, which base-update-wait.ts says instead.
|
|
32
|
+
*/
|
|
33
|
+
export const latestChangesConflictsMessage = (count) => `Latest changes added. ${count === undefined ? 'Some fields need' : fieldsNeed(count)} your choice.`;
|
|
34
|
+
/** The bar's "Needs action" detail while the branch's record of conflicts is held. */
|
|
35
|
+
export const conflictRecordHeldMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved until an admin releases the hold.";
|
|
36
|
+
/** The same, to an admin, who has the way out beside it. */
|
|
37
|
+
export const conflictRecordHeldAdminMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved. Use \"Release hold\" to let it go on.";
|
|
38
|
+
/** What "Release hold" gives up, in its confirm. */
|
|
39
|
+
export const releaseHoldConsequences = "Releasing lets this branch be submitted and approved again. The list of fields that needed a choice is lost for good: each field keeps the value it has now, and no one will be asked to choose. Check the branch's changes first.";
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const EDITOR_ACTIONS: {
|
|
|
9
9
|
readonly refreshFromServer: "Refresh from server";
|
|
10
10
|
readonly submitForReview: "Submit for review";
|
|
11
11
|
readonly withdrawFromReview: "Withdraw from review";
|
|
12
|
+
readonly getLatestChanges: "Get latest changes";
|
|
12
13
|
readonly approve: "Approve";
|
|
13
14
|
readonly review: "Review";
|
|
14
15
|
readonly requestChanges: "Request changes";
|
|
@@ -28,7 +29,18 @@ export declare const EDITOR_ACTIONS: {
|
|
|
28
29
|
readonly redoAllChanges: "Redo all changes";
|
|
29
30
|
readonly restoreEntry: "Restore entry";
|
|
30
31
|
readonly deleteEntry: "Delete entry";
|
|
32
|
+
readonly useLatestVersion: "Use latest version";
|
|
33
|
+
readonly keepYourVersion: "Keep your version";
|
|
34
|
+
readonly useBoth: "Use both";
|
|
35
|
+
readonly markResolved: "Mark resolved";
|
|
36
|
+
readonly releaseHold: "Release hold";
|
|
37
|
+
readonly removeIt: "Remove it";
|
|
38
|
+
readonly keepIt: "Keep it";
|
|
39
|
+
readonly keepItDeleted: "Keep it deleted";
|
|
40
|
+
readonly chooseAgain: "Choose again";
|
|
31
41
|
};
|
|
42
|
+
/** Withdraw's confirm on a branch that conflicts with its base, which then gets the latest changes. */
|
|
43
|
+
export declare const WITHDRAW_AND_GET_LATEST = "Withdraw and get latest changes";
|
|
32
44
|
/**
|
|
33
45
|
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
34
46
|
* `one` and `many` name what the list holds.
|
package/dist/editor/copy.js
CHANGED
|
@@ -9,6 +9,7 @@ export const EDITOR_ACTIONS = {
|
|
|
9
9
|
refreshFromServer: 'Refresh from server',
|
|
10
10
|
submitForReview: 'Submit for review',
|
|
11
11
|
withdrawFromReview: 'Withdraw from review',
|
|
12
|
+
getLatestChanges: 'Get latest changes',
|
|
12
13
|
approve: 'Approve',
|
|
13
14
|
review: 'Review',
|
|
14
15
|
requestChanges: 'Request changes',
|
|
@@ -28,7 +29,18 @@ export const EDITOR_ACTIONS = {
|
|
|
28
29
|
redoAllChanges: 'Redo all changes',
|
|
29
30
|
restoreEntry: 'Restore entry',
|
|
30
31
|
deleteEntry: 'Delete entry',
|
|
32
|
+
useLatestVersion: 'Use latest version',
|
|
33
|
+
keepYourVersion: 'Keep your version',
|
|
34
|
+
useBoth: 'Use both',
|
|
35
|
+
markResolved: 'Mark resolved',
|
|
36
|
+
releaseHold: 'Release hold',
|
|
37
|
+
removeIt: 'Remove it',
|
|
38
|
+
keepIt: 'Keep it',
|
|
39
|
+
keepItDeleted: 'Keep it deleted',
|
|
40
|
+
chooseAgain: 'Choose again',
|
|
31
41
|
};
|
|
42
|
+
/** Withdraw's confirm on a branch that conflicts with its base, which then gets the latest changes. */
|
|
43
|
+
export const WITHDRAW_AND_GET_LATEST = 'Withdraw and get latest changes';
|
|
32
44
|
/**
|
|
33
45
|
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
34
46
|
* `one` and `many` name what the list holds.
|
|
@@ -151,8 +151,9 @@ export interface EntryDiffInput {
|
|
|
151
151
|
* text's spacing, so one side read back from its parts would not be the text it was.
|
|
152
152
|
* Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
|
|
153
153
|
* diff runs until the later `wordDeadline`.
|
|
154
|
-
* @internal Exported for tests.
|
|
155
154
|
*/
|
|
156
155
|
export declare function textDiff(before: string, after: string, long?: boolean, deadline?: number, wordDeadline?: number): TextDiff | undefined;
|
|
156
|
+
/** `value` of `field` as the drawer shows it whole, for a value a conflict compares whole. */
|
|
157
|
+
export declare function displayWhole(field: FieldConfig | undefined, value: unknown, referenceTitle?: (id: string) => string | undefined): DisplayValue;
|
|
157
158
|
/** The entry's changes between `before` and `after`, in schema order. */
|
|
158
159
|
export declare function computeEntryDiff({ fields, before, after, referenceTitle, marks, }: EntryDiffInput): EntryDiff;
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* so a reordered item reads as moved, not as one removed and one added.
|
|
6
6
|
*/
|
|
7
7
|
import { diffLines, diffWordsWithSpace } from 'diff';
|
|
8
|
-
import equal from 'fast-deep-equal';
|
|
9
8
|
import { ITEM_KEY } from '../item-keys.js';
|
|
9
|
+
import { isEmptyValue as isEmpty, normalizedValue as normalized, sameValue, } from '../merge/value-equality.js';
|
|
10
10
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
11
11
|
import { isCalendarDay, localMidnight } from '../utils/calendar-day.js';
|
|
12
12
|
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
@@ -40,25 +40,6 @@ const formValue = (fields, side) => {
|
|
|
40
40
|
return side.data;
|
|
41
41
|
return { ...side.data, [side.bodyFieldName ?? findBodyFieldName(fields)]: side.body };
|
|
42
42
|
};
|
|
43
|
-
const isEmpty = (value) => value === undefined ||
|
|
44
|
-
value === null ||
|
|
45
|
-
value === '' ||
|
|
46
|
-
(Array.isArray(value) && value.length === 0) ||
|
|
47
|
-
(isPlainRecord(value) && Object.keys(value).every((k) => k === ITEM_KEY || isEmpty(value[k])));
|
|
48
|
-
/** `value` without item keys and empty members, so equal content compares equal. */
|
|
49
|
-
const normalized = (value) => {
|
|
50
|
-
if (Array.isArray(value))
|
|
51
|
-
return value.map(normalized);
|
|
52
|
-
if (!isPlainRecord(value))
|
|
53
|
-
return value;
|
|
54
|
-
const out = {};
|
|
55
|
-
for (const [k, v] of Object.entries(value)) {
|
|
56
|
-
if (k !== ITEM_KEY && !isEmpty(v))
|
|
57
|
-
out[k] = normalized(v);
|
|
58
|
-
}
|
|
59
|
-
return out;
|
|
60
|
-
};
|
|
61
|
-
const sameValue = (a, b) => (isEmpty(a) && isEmpty(b)) || equal(normalized(a), normalized(b));
|
|
62
43
|
const at = (parent, label, key, segment) => ({
|
|
63
44
|
path: [...parent.path, label],
|
|
64
45
|
key: parent.key ? `${parent.key}.${key}` : key,
|
|
@@ -505,7 +486,6 @@ const splitLines = (value) => {
|
|
|
505
486
|
* text's spacing, so one side read back from its parts would not be the text it was.
|
|
506
487
|
* Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
|
|
507
488
|
* diff runs until the later `wordDeadline`.
|
|
508
|
-
* @internal Exported for tests.
|
|
509
489
|
*/
|
|
510
490
|
export function textDiff(before, after, long = false, deadline = Date.now() + DIFF_TIMEOUT_MS, wordDeadline = Date.now() + SHORT_DIFF_TIMEOUT_MS) {
|
|
511
491
|
// jsdiff restarts its clock on each call, so each is given only what is left of the budget.
|
|
@@ -593,6 +573,10 @@ const withContext = (lines) => {
|
|
|
593
573
|
}
|
|
594
574
|
return out;
|
|
595
575
|
};
|
|
576
|
+
/** `value` of `field` as the drawer shows it whole, for a value a conflict compares whole. */
|
|
577
|
+
export function displayWhole(field, value, referenceTitle) {
|
|
578
|
+
return new Walker(referenceTitle, 0, 0).display(field, value);
|
|
579
|
+
}
|
|
596
580
|
/** The entry's changes between `before` and `after`, in schema order. */
|
|
597
581
|
export function computeEntryDiff({ fields, before, after, referenceTitle, marks = true, }) {
|
|
598
582
|
// One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS. Without
|