canopycms 0.11.1 → 0.12.1
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 +174 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-changes.d.ts +1 -0
- package/dist/api/branch-changes.js +1 -1
- package/dist/api/branch-entry-changes.d.ts +92 -7
- package/dist/api/branch-entry-changes.js +272 -38
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +17 -2
- package/dist/api/client.js +26 -0
- 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 +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- 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 +9713 -1493
- package/dist/cli/generate-ai-content.js +415 -244
- package/dist/cli/init.js +6 -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 +65 -3
- package/dist/content-store.js +121 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +549 -53
- 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/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -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 +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
- package/dist/editor/components/EntryChangesDrawer.js +164 -162
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +4 -6
- 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 +90 -0
- package/dist/editor/conflict-cards.js +279 -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 +20 -0
- package/dist/editor/copy.js +20 -0
- package/dist/editor/entry-diff.d.ts +18 -2
- package/dist/editor/entry-diff.js +26 -32
- package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- 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 +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- 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 +52 -0
- package/dist/editor/undo-change.js +241 -0
- package/dist/git-manager.d.ts +5 -0
- package/dist/git-manager.js +30 -0
- 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 +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -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/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- 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/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 +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which item of the ancestor's list each item of one side's list is, for the field merge. A side's
|
|
3
|
+
* item is the ancestor's item when it carries a key the ancestor knows; otherwise it is ALIGNED to
|
|
4
|
+
* the ancestor the way diff3 aligns lines: equal content in order, then equal content out of
|
|
5
|
+
* order, then, inside a gap between two matches, by the values it kept, and last by place.
|
|
6
|
+
* Anything left is new on that side. Aligning items no key names is the engine's one heuristic:
|
|
7
|
+
* see `merge/AGENTS.md`.
|
|
8
|
+
*/
|
|
9
|
+
/** Which ancestor item each item of a side's list is. */
|
|
10
|
+
export interface Alignment {
|
|
11
|
+
/** Per item: the ancestor item's key, or for an item new on this side an identity of its own. */
|
|
12
|
+
ids: string[];
|
|
13
|
+
/** Items matched by their place alone, sharing no value with any ancestor item left in the gap. */
|
|
14
|
+
byPlace: Set<number>;
|
|
15
|
+
/**
|
|
16
|
+
* An item under a stale key is left unpaired beside ancestor items still unmatched: it is one
|
|
17
|
+
* of them, or new, but not clearly which. It is named new.
|
|
18
|
+
*/
|
|
19
|
+
unsure: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Each item of `items`' identity: the key of the ancestor item it is, or for an item new on this
|
|
23
|
+
* side an identity of its own. `ancestorKeys` are the ancestor's items' keys as a read gives them.
|
|
24
|
+
* An item with a key of its own is named by the key a read gives it: that key the first time the
|
|
25
|
+
* list holds it, else a hash of its content. A name the ancestor's read gave, or another key a
|
|
26
|
+
* read could have given an ancestor item ({@link ancestorAliases}), is that ancestor item; any
|
|
27
|
+
* other key marks the item new when it was minted for it, the ancestor knows it (a copy), or the
|
|
28
|
+
* ancestor's read gave it to an item of another list (`inEntry`, every key the ancestor's read
|
|
29
|
+
* gives across the entry): that item moved here.
|
|
30
|
+
*
|
|
31
|
+
* The rest align as diff3 aligns lines: equal content in order, then equal content out of order
|
|
32
|
+
* (a move), then, in each gap between two matches, by shared values and last by place
|
|
33
|
+
* ({@link pairInGap}).
|
|
34
|
+
* Undefined when the diff ran out of time.
|
|
35
|
+
*/
|
|
36
|
+
export declare function alignToAncestor(items: readonly unknown[], ancestorItems: readonly unknown[], ancestorKeys: readonly string[], side: string, inEntry: ReadonlySet<string>, movedIn: ReadonlySet<string>): Alignment | undefined;
|
|
37
|
+
/** True for an identity {@link alignToAncestor} gave an item new on its side. */
|
|
38
|
+
export declare const isNewIdentity: (id: string) => boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Pairs the items new on the base with the items new on the branch that are the same addition:
|
|
41
|
+
* the same key, else equal content, in order. The branch's identity replaces the base's in
|
|
42
|
+
* `baseIds` for each pair, so an item both sides added alike is one item. Pairing is skipped by
|
|
43
|
+
* content when the diff runs out of time: the additions stay apart, and none is lost.
|
|
44
|
+
*/
|
|
45
|
+
export declare function pairNewItems(baseItems: readonly unknown[], baseIds: string[], branchItems: readonly unknown[], branchIds: readonly string[]): void;
|
|
46
|
+
/**
|
|
47
|
+
* The order of the merged list's identities: one side's order, with the items only the other side
|
|
48
|
+
* holds placed after the item that precedes them there. The branch's order leads unless the
|
|
49
|
+
* branch kept the ancestor's order and the base did not, so a reorder on one side applies, and a
|
|
50
|
+
* reorder on both sides takes the branch's.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mergeOrder(ancestorIds: readonly string[], baseIds: readonly string[], branchIds: readonly string[]): string[];
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which item of the ancestor's list each item of one side's list is, for the field merge. A side's
|
|
3
|
+
* item is the ancestor's item when it carries a key the ancestor knows; otherwise it is ALIGNED to
|
|
4
|
+
* the ancestor the way diff3 aligns lines: equal content in order, then equal content out of
|
|
5
|
+
* order, then, inside a gap between two matches, by the values it kept, and last by place.
|
|
6
|
+
* Anything left is new on that side. Aligning items no key names is the engine's one heuristic:
|
|
7
|
+
* see `merge/AGENTS.md`.
|
|
8
|
+
*/
|
|
9
|
+
import { ITEM_KEY, hasContentKeyShape, hasMachineKeyShape, isItemKey, itemKeysAsRead, } from '../item-keys.js';
|
|
10
|
+
import { isBlockStructuralKey } from '../validation/block-structural-keys.js';
|
|
11
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
12
|
+
import { commonPairs } from './diff3.js';
|
|
13
|
+
import { isEmptyValue, valueToken } from './value-equality.js';
|
|
14
|
+
/** Marks an identity no file holds: a side's new item. Item keys never contain it. */
|
|
15
|
+
const INTERNAL = '\u0000';
|
|
16
|
+
const ownKey = (item) => isPlainRecord(item) && isItemKey(item[ITEM_KEY]) ? item[ITEM_KEY] : undefined;
|
|
17
|
+
/** `value` with every item key dropped, and each bare date at UTC midnight as its day. */
|
|
18
|
+
const asKeyedContent = (value, dropAll) => {
|
|
19
|
+
if (value instanceof Date && value.getTime() % 86_400_000 === 0) {
|
|
20
|
+
return value.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.map((v) => asKeyedContent(v, dropAll));
|
|
24
|
+
if (!isPlainRecord(value))
|
|
25
|
+
return value;
|
|
26
|
+
return Object.fromEntries(Object.entries(value)
|
|
27
|
+
.filter(([k]) => !(dropAll && k === ITEM_KEY))
|
|
28
|
+
.map(([k, v]) => [k, asKeyedContent(v, dropAll)]));
|
|
29
|
+
};
|
|
30
|
+
const withoutOwnKey = (item) => Object.fromEntries(Object.entries(item).filter(([k]) => k !== ITEM_KEY));
|
|
31
|
+
/**
|
|
32
|
+
* True when `key` is a hash of `item`'s own content (`-2` and on for a repeat), as a read or a key
|
|
33
|
+
* backfill writes it, so it says nothing about which item this is; any other key was minted.
|
|
34
|
+
*/
|
|
35
|
+
const isContentKey = (item, key) => {
|
|
36
|
+
const content = withoutOwnKey(item);
|
|
37
|
+
const hashes = [content, asKeyedContent(content, false), asKeyedContent(content, true)].map((c) => itemKeysAsRead([c])[0]);
|
|
38
|
+
const base = key.replace(/-\d+$/, '');
|
|
39
|
+
return hashes.includes(key) || hashes.includes(base);
|
|
40
|
+
};
|
|
41
|
+
/** An item carrying a usable key that is not a hash of its content: a key minted for it. */
|
|
42
|
+
const isMinted = (item) => {
|
|
43
|
+
const key = ownKey(item);
|
|
44
|
+
return key !== undefined && !isContentKey(item, key);
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The other keys a read could have given each ancestor item, mapped to the item's key: its
|
|
48
|
+
* content hashed with bare dates as days or with the keys inside it dropped, as a read that knew
|
|
49
|
+
* the schema, or ran before nested lists were keyed, hashed it. A side's key matching one names
|
|
50
|
+
* that ancestor item: an editor's first save keys an item as read and may edit it in the same save.
|
|
51
|
+
*/
|
|
52
|
+
const ancestorAliases = (ancestorItems, ancestorKeys) => {
|
|
53
|
+
const aliases = new Map();
|
|
54
|
+
for (const dropAll of [false, true]) {
|
|
55
|
+
const variants = ancestorItems.map((item) => isPlainRecord(item) ? asKeyedContent(withoutOwnKey(item), dropAll) : item);
|
|
56
|
+
itemKeysAsRead(variants).forEach((alias, i) => {
|
|
57
|
+
if (alias !== undefined && !aliases.has(alias))
|
|
58
|
+
aliases.set(alias, ancestorKeys[i]);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return aliases;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Each item of `items`' identity: the key of the ancestor item it is, or for an item new on this
|
|
65
|
+
* side an identity of its own. `ancestorKeys` are the ancestor's items' keys as a read gives them.
|
|
66
|
+
* An item with a key of its own is named by the key a read gives it: that key the first time the
|
|
67
|
+
* list holds it, else a hash of its content. A name the ancestor's read gave, or another key a
|
|
68
|
+
* read could have given an ancestor item ({@link ancestorAliases}), is that ancestor item; any
|
|
69
|
+
* other key marks the item new when it was minted for it, the ancestor knows it (a copy), or the
|
|
70
|
+
* ancestor's read gave it to an item of another list (`inEntry`, every key the ancestor's read
|
|
71
|
+
* gives across the entry): that item moved here.
|
|
72
|
+
*
|
|
73
|
+
* The rest align as diff3 aligns lines: equal content in order, then equal content out of order
|
|
74
|
+
* (a move), then, in each gap between two matches, by shared values and last by place
|
|
75
|
+
* ({@link pairInGap}).
|
|
76
|
+
* Undefined when the diff ran out of time.
|
|
77
|
+
*/
|
|
78
|
+
export function alignToAncestor(items, ancestorItems, ancestorKeys, side, inEntry, movedIn) {
|
|
79
|
+
const known = new Set(ancestorKeys);
|
|
80
|
+
const aliases = ancestorAliases(ancestorItems, ancestorKeys);
|
|
81
|
+
const readKeys = itemKeysAsRead(items);
|
|
82
|
+
const ids = items.map(() => undefined);
|
|
83
|
+
const taken = new Set();
|
|
84
|
+
const minted = new Set();
|
|
85
|
+
// Items whose key the ancestor no longer knows, by how they may still find their item.
|
|
86
|
+
const byContent = new Set();
|
|
87
|
+
const stale = new Set();
|
|
88
|
+
// A read keys an item by content (twins as K, K-2, ...), and a git-level merge bringing in the
|
|
89
|
+
// base's edit of one twin leaves its key on the edited copy, which the ancestor then reads, or a
|
|
90
|
+
// backfill keys, as another item's. So an item under a read key holding the item it names is
|
|
91
|
+
// that item, and one holding exactly another ancestor item no key on this side names is that
|
|
92
|
+
// one (an item a key names gives way only to one under its read key): also, with no git-level
|
|
93
|
+
// merge, an item a side edited to read exactly like one it deleted. A moved key names its item.
|
|
94
|
+
const held = new Set([...movedIn, ...items.flatMap((item) => ownKey(item) ?? [])]);
|
|
95
|
+
const unheld = new Map();
|
|
96
|
+
const unheldFresh = new Set();
|
|
97
|
+
ancestorItems.forEach((item, i) => {
|
|
98
|
+
if (held.has(ancestorKeys[i]))
|
|
99
|
+
return;
|
|
100
|
+
const token = valueToken(item);
|
|
101
|
+
unheld.set(token, (unheld.get(token) ?? 0) + 1);
|
|
102
|
+
if (!isMinted(item))
|
|
103
|
+
unheldFresh.add(token);
|
|
104
|
+
});
|
|
105
|
+
const at = new Map(ancestorKeys.map((key, i) => [key, i]));
|
|
106
|
+
const usurped = (j, own) => {
|
|
107
|
+
if (!hasContentKeyShape(own))
|
|
108
|
+
return false;
|
|
109
|
+
const content = valueToken(items[j]);
|
|
110
|
+
const holders = unheld.get(content) ?? 0;
|
|
111
|
+
if (holders === 0)
|
|
112
|
+
return false;
|
|
113
|
+
const read = readKeys[j];
|
|
114
|
+
const named = read !== undefined && known.has(read) ? read : aliases.get(own);
|
|
115
|
+
const i = named === undefined ? undefined : at.get(named);
|
|
116
|
+
// A key that has gone stale on both sides is history, not content: it names its item.
|
|
117
|
+
if (i !== undefined) {
|
|
118
|
+
if (isMinted(ancestorItems[i]) && isMinted(items[j]))
|
|
119
|
+
return false;
|
|
120
|
+
// A key takes only an unheld item still under its read key: one gone stale was edited.
|
|
121
|
+
if (!unheldFresh.has(content))
|
|
122
|
+
return false;
|
|
123
|
+
return valueToken(ancestorItems[i]) !== content;
|
|
124
|
+
}
|
|
125
|
+
// A key naming no item here pairs by content only where one item holds it, twins by place.
|
|
126
|
+
return holders === 1;
|
|
127
|
+
};
|
|
128
|
+
items.forEach((item, j) => {
|
|
129
|
+
const own = ownKey(item);
|
|
130
|
+
const read = readKeys[j];
|
|
131
|
+
if (own !== undefined && usurped(j, own)) {
|
|
132
|
+
minted.add(j);
|
|
133
|
+
byContent.add(j);
|
|
134
|
+
stale.add(j);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// A keyless item's read key is a hash of its content, which repeats among identical items:
|
|
138
|
+
// those align by content and order below, never by the hash.
|
|
139
|
+
const id = own === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: read !== undefined && known.has(read)
|
|
142
|
+
? read
|
|
143
|
+
: !known.has(own)
|
|
144
|
+
? aliases.get(own)
|
|
145
|
+
: undefined;
|
|
146
|
+
if (id !== undefined && !taken.has(id)) {
|
|
147
|
+
ids[j] = id;
|
|
148
|
+
taken.add(id);
|
|
149
|
+
}
|
|
150
|
+
else if (own !== undefined &&
|
|
151
|
+
read !== undefined &&
|
|
152
|
+
!known.has(own) &&
|
|
153
|
+
hasMachineKeyShape(own) &&
|
|
154
|
+
inEntry.has(read) &&
|
|
155
|
+
!(hasContentKeyShape(own) && (unheld.get(valueToken(item)) ?? 0) > 0)) {
|
|
156
|
+
// An ancestor item of another list, moved here with its key: new to this list. Not under a
|
|
157
|
+
// read key holding exactly an item of this list no key here names: a reworded twin.
|
|
158
|
+
minted.add(j);
|
|
159
|
+
}
|
|
160
|
+
else if (own !== undefined && !known.has(own) && isMinted(item)) {
|
|
161
|
+
// A key the ancestor doesn't know: minted for a new item, stripped there by hand, or a read
|
|
162
|
+
// key for content a sync has since replaced with the base's edit (`stale`). See below.
|
|
163
|
+
minted.add(j);
|
|
164
|
+
byContent.add(j);
|
|
165
|
+
if (hasContentKeyShape(own))
|
|
166
|
+
stale.add(j);
|
|
167
|
+
}
|
|
168
|
+
else if (own !== undefined && (known.has(own) || isMinted(item))) {
|
|
169
|
+
minted.add(j);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
const match = (j, i) => {
|
|
173
|
+
ids[j] = ancestorKeys[i];
|
|
174
|
+
taken.add(ancestorKeys[i]);
|
|
175
|
+
};
|
|
176
|
+
// Items matched by key anchor the alignment too, where they kept their order.
|
|
177
|
+
const ancestorTokens = ancestorItems.map((item, i) => taken.has(ancestorKeys[i]) ? `${INTERNAL}k${ancestorKeys[i]}` : `c${valueToken(item)}`);
|
|
178
|
+
const sideTokens = items.map((item, j) => {
|
|
179
|
+
const id = ids[j];
|
|
180
|
+
if (minted.has(j))
|
|
181
|
+
return `${INTERNAL}m${j}`;
|
|
182
|
+
return id !== undefined ? `${INTERNAL}k${id}` : `c${valueToken(item)}`;
|
|
183
|
+
});
|
|
184
|
+
const anchors = commonPairs(ancestorTokens, sideTokens);
|
|
185
|
+
if (!anchors)
|
|
186
|
+
return undefined;
|
|
187
|
+
for (const [i, j] of anchors)
|
|
188
|
+
if (ids[j] === undefined)
|
|
189
|
+
match(j, i);
|
|
190
|
+
// An item whose content an unmatched ancestor item has, out of order: it was moved.
|
|
191
|
+
const unmatched = new Map();
|
|
192
|
+
ancestorItems.forEach((item, i) => {
|
|
193
|
+
if (taken.has(ancestorKeys[i]))
|
|
194
|
+
return;
|
|
195
|
+
const token = valueToken(item);
|
|
196
|
+
unmatched.set(token, [...(unmatched.get(token) ?? []), i]);
|
|
197
|
+
});
|
|
198
|
+
items.forEach((item, j) => {
|
|
199
|
+
if (ids[j] !== undefined || minted.has(j))
|
|
200
|
+
return;
|
|
201
|
+
const i = unmatched.get(valueToken(item))?.shift();
|
|
202
|
+
if (i !== undefined)
|
|
203
|
+
match(j, i);
|
|
204
|
+
});
|
|
205
|
+
// Such an item is the ancestor item holding its content, or for a stale key the one holding
|
|
206
|
+
// most of its values; never one under a key minted for it, though one under a read key gone
|
|
207
|
+
// stale may. Only a stale key may still pair by place.
|
|
208
|
+
const candidates = ancestorItems.flatMap((item, i) => taken.has(ancestorKeys[i]) || (isMinted(item) && !hasContentKeyShape(ancestorKeys[i]))
|
|
209
|
+
? []
|
|
210
|
+
: [i]);
|
|
211
|
+
const open = (i) => !taken.has(ancestorKeys[i]);
|
|
212
|
+
for (const j of byContent) {
|
|
213
|
+
const i = candidates.find((k) => open(k) && valueToken(ancestorItems[k]) === valueToken(items[j]));
|
|
214
|
+
if (i !== undefined)
|
|
215
|
+
match(j, i);
|
|
216
|
+
}
|
|
217
|
+
// By values only with one clear match holding most of what it held, and never for content an
|
|
218
|
+
// item of another list holds exactly: that item moved here.
|
|
219
|
+
const byValues = valueIndex(candidates, ancestorItems);
|
|
220
|
+
for (const j of stale) {
|
|
221
|
+
if (ids[j] !== undefined)
|
|
222
|
+
continue;
|
|
223
|
+
const content = itemKeysAsRead([withoutOwnKey(items[j])])[0];
|
|
224
|
+
if (content !== undefined && !known.has(content) && inEntry.has(content)) {
|
|
225
|
+
stale.delete(j);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const i = byValues.clear(items[j], open);
|
|
229
|
+
if (i !== undefined)
|
|
230
|
+
match(j, i);
|
|
231
|
+
}
|
|
232
|
+
const bounds = [
|
|
233
|
+
[-1, -1],
|
|
234
|
+
...anchors,
|
|
235
|
+
[ancestorItems.length, items.length],
|
|
236
|
+
];
|
|
237
|
+
const byPlace = new Set();
|
|
238
|
+
for (let n = 0; n + 1 < bounds.length; n++) {
|
|
239
|
+
const ancestorGap = [];
|
|
240
|
+
const sideGap = [];
|
|
241
|
+
for (let i = bounds[n][0] + 1; i < bounds[n + 1][0]; i++) {
|
|
242
|
+
if (!taken.has(ancestorKeys[i]))
|
|
243
|
+
ancestorGap.push(i);
|
|
244
|
+
}
|
|
245
|
+
for (let j = bounds[n][1] + 1; j < bounds[n + 1][1]; j++) {
|
|
246
|
+
if (ids[j] === undefined && (!minted.has(j) || stale.has(j)))
|
|
247
|
+
sideGap.push(j);
|
|
248
|
+
}
|
|
249
|
+
// A keyless item holding an ancestor item of another list exactly may have moved here: it
|
|
250
|
+
// pairs only by its values, never by place.
|
|
251
|
+
const movedHere = (j) => {
|
|
252
|
+
const read = readKeys[j];
|
|
253
|
+
return read !== undefined && !known.has(read) && inEntry.has(read);
|
|
254
|
+
};
|
|
255
|
+
// An item under a stale key existed before, so it may still pair by place, never by values.
|
|
256
|
+
const placeOnly = (j) => stale.has(j);
|
|
257
|
+
const paired = pairInGap(sideGap, items, ancestorGap, ancestorItems, movedHere, placeOnly);
|
|
258
|
+
for (const [j, i, place] of paired) {
|
|
259
|
+
match(j, i);
|
|
260
|
+
if (place)
|
|
261
|
+
byPlace.add(j);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// An ancestor item under a stale key of its own is one a side may hold under another.
|
|
265
|
+
const unmatched_ = ancestorItems.some((item, i) => !taken.has(ancestorKeys[i]) && (!isMinted(item) || hasContentKeyShape(ancestorKeys[i])));
|
|
266
|
+
const unsure = unmatched_ && [...stale].some((j) => ids[j] === undefined);
|
|
267
|
+
return { ids: ids.map((id, j) => id ?? newIdentity(side, j)), byPlace, unsure };
|
|
268
|
+
}
|
|
269
|
+
/** Above this many value matches counted in one gap, its items pair by place or not at all. */
|
|
270
|
+
const MAX_SCORE_STEPS = 1_000_000;
|
|
271
|
+
/** `item`'s content members: a block's data inside its `value`, never its key or template. */
|
|
272
|
+
const contentMembers = (item) => {
|
|
273
|
+
if (!isPlainRecord(item))
|
|
274
|
+
return [];
|
|
275
|
+
const data = typeof item.template === 'string' && isPlainRecord(item.value) ? item.value : item;
|
|
276
|
+
return Object.entries(data).filter(([name, value]) => name !== ITEM_KEY && !isBlockStructuralKey(name) && !isEmptyValue(value));
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* Pairs a gap's side items with its ancestor items: each with the one holding most of its values,
|
|
280
|
+
* the earliest on a tie. When as many are left on both sides, they pair in order by place (`true`):
|
|
281
|
+
* only keyless items that did not come from another list (`movedHere`), and only with ancestor
|
|
282
|
+
* items not carrying a key minted for them. The rest are adds and removes, so a delete and an add
|
|
283
|
+
* in one place never read as an edit.
|
|
284
|
+
*/
|
|
285
|
+
function pairInGap(sideGap, items, ancestorGap, ancestorItems, movedHere, placeOnly) {
|
|
286
|
+
const index = valueIndex(ancestorGap, ancestorItems);
|
|
287
|
+
const free = new Set(ancestorGap);
|
|
288
|
+
const pairs = [];
|
|
289
|
+
const unpaired = [];
|
|
290
|
+
for (const j of sideGap) {
|
|
291
|
+
if (placeOnly(j)) {
|
|
292
|
+
unpaired.push(j);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const best = index.best(items[j], (i) => free.has(i));
|
|
296
|
+
if (index.overBudget())
|
|
297
|
+
return byPlace(sideGap, ancestorGap, items, ancestorItems);
|
|
298
|
+
if (best === undefined) {
|
|
299
|
+
// An item with a key of its own that keeps no value of any is not one of them.
|
|
300
|
+
if (ownKey(items[j]) === undefined && !movedHere(j))
|
|
301
|
+
unpaired.push(j);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
free.delete(best);
|
|
305
|
+
pairs.push([j, best, false]);
|
|
306
|
+
}
|
|
307
|
+
// An ancestor item with a key minted for it is that item only under the key.
|
|
308
|
+
const left = [...free].filter((i) => !isMinted(ancestorItems[i])).sort((x, y) => x - y);
|
|
309
|
+
if (left.length === unpaired.length)
|
|
310
|
+
unpaired.forEach((j, k) => pairs.push([j, left[k], true]));
|
|
311
|
+
return pairs;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The ancestor items at `indices` indexed by their member values: `best(item, open)` is the open
|
|
315
|
+
* one holding most of `item`'s values, the earliest on a tie, or undefined when none holds any.
|
|
316
|
+
* Each value `best` matches counts against {@link MAX_SCORE_STEPS}, past which `overBudget()` is
|
|
317
|
+
* true.
|
|
318
|
+
*/
|
|
319
|
+
function valueIndex(indices, ancestorItems) {
|
|
320
|
+
const holders = new Map();
|
|
321
|
+
for (const i of indices) {
|
|
322
|
+
for (const [name, value] of contentMembers(ancestorItems[i])) {
|
|
323
|
+
const at = `${name}\u0000${valueToken(value)}`;
|
|
324
|
+
holders.set(at, [...(holders.get(at) ?? []), i]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let steps = 0;
|
|
328
|
+
return {
|
|
329
|
+
best(item, open) {
|
|
330
|
+
const scores = new Map();
|
|
331
|
+
for (const [name, value] of contentMembers(item)) {
|
|
332
|
+
for (const i of holders.get(`${name}\u0000${valueToken(value)}`) ?? []) {
|
|
333
|
+
steps++;
|
|
334
|
+
if (open(i))
|
|
335
|
+
scores.set(i, (scores.get(i) ?? 0) + 1);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
let best;
|
|
339
|
+
let bestScore = 0;
|
|
340
|
+
for (const [i, score] of scores) {
|
|
341
|
+
if (score > bestScore || (score === bestScore && best !== undefined && i < best)) {
|
|
342
|
+
;
|
|
343
|
+
[best, bestScore] = [i, score];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return best;
|
|
347
|
+
},
|
|
348
|
+
/** `best`, when no other open item holds as many, and those are over half of its own values. */
|
|
349
|
+
clear(item, open) {
|
|
350
|
+
const scores = new Map();
|
|
351
|
+
for (const [name, value] of contentMembers(item)) {
|
|
352
|
+
for (const i of holders.get(`${name}\u0000${valueToken(value)}`) ?? []) {
|
|
353
|
+
if (open(i))
|
|
354
|
+
scores.set(i, (scores.get(i) ?? 0) + 1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const ranked = [...scores].sort(([, x], [, y]) => y - x);
|
|
358
|
+
const [first, second] = ranked;
|
|
359
|
+
if (!first || (second && second[1] === first[1]))
|
|
360
|
+
return undefined;
|
|
361
|
+
return first[1] * 2 > contentMembers(ancestorItems[first[0]]).length ? first[0] : undefined;
|
|
362
|
+
},
|
|
363
|
+
overBudget: () => steps > MAX_SCORE_STEPS,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/** A gap too large to score: pairs as `pairInGap`'s by-place step does, from the start. */
|
|
367
|
+
function byPlace(sideGap, ancestorGap, items, ancestorItems) {
|
|
368
|
+
const side = sideGap.filter((j) => ownKey(items[j]) === undefined);
|
|
369
|
+
const ancestor = ancestorGap.filter((i) => !isMinted(ancestorItems[i]));
|
|
370
|
+
return side.length === ancestor.length ? side.map((j, k) => [j, ancestor[k], true]) : [];
|
|
371
|
+
}
|
|
372
|
+
/** The identity of the `j`th item of `side`'s list, new on that side. */
|
|
373
|
+
const newIdentity = (side, j) => `${INTERNAL}${side}${j}`;
|
|
374
|
+
/** True for an identity {@link alignToAncestor} gave an item new on its side. */
|
|
375
|
+
export const isNewIdentity = (id) => id.startsWith(INTERNAL);
|
|
376
|
+
/**
|
|
377
|
+
* Pairs the items new on the base with the items new on the branch that are the same addition:
|
|
378
|
+
* the same key, else equal content, in order. The branch's identity replaces the base's in
|
|
379
|
+
* `baseIds` for each pair, so an item both sides added alike is one item. Pairing is skipped by
|
|
380
|
+
* content when the diff runs out of time: the additions stay apart, and none is lost.
|
|
381
|
+
*/
|
|
382
|
+
export function pairNewItems(baseItems, baseIds, branchItems, branchIds) {
|
|
383
|
+
const baseNew = baseIds.flatMap((id, j) => (isNewIdentity(id) ? [j] : []));
|
|
384
|
+
const branchNew = branchIds.flatMap((id, j) => (isNewIdentity(id) ? [j] : []));
|
|
385
|
+
const pairedBase = new Set();
|
|
386
|
+
const pairedBranch = new Set();
|
|
387
|
+
// The same key as a read gives it: a key both added, or a repeat both added alike.
|
|
388
|
+
const [baseRead, branchRead] = [itemKeysAsRead(baseItems), itemKeysAsRead(branchItems)];
|
|
389
|
+
const branchByKey = new Map();
|
|
390
|
+
for (const j of branchNew) {
|
|
391
|
+
const key = branchRead[j];
|
|
392
|
+
if (key !== undefined && !branchByKey.has(key))
|
|
393
|
+
branchByKey.set(key, j);
|
|
394
|
+
}
|
|
395
|
+
for (const j of baseNew) {
|
|
396
|
+
const key = baseRead[j];
|
|
397
|
+
const match = key === undefined ? undefined : branchByKey.get(key);
|
|
398
|
+
if (match !== undefined && !pairedBranch.has(match)) {
|
|
399
|
+
baseIds[j] = branchIds[match];
|
|
400
|
+
pairedBase.add(j);
|
|
401
|
+
pairedBranch.add(match);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Equal content pairs two additions, unless both carry minted keys: two items reading alike.
|
|
405
|
+
const restBase = baseNew.filter((j) => !pairedBase.has(j));
|
|
406
|
+
const restBranch = branchNew.filter((j) => !pairedBranch.has(j));
|
|
407
|
+
const pairs = (commonPairs(restBase.map((j) => valueToken(baseItems[j])), restBranch.map((j) => valueToken(branchItems[j]))) ?? []).filter(([x, y]) => !(isMinted(baseItems[restBase[x]]) && isMinted(branchItems[restBranch[y]])));
|
|
408
|
+
for (const [x, y] of pairs)
|
|
409
|
+
baseIds[restBase[x]] = branchIds[restBranch[y]];
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The order of the merged list's identities: one side's order, with the items only the other side
|
|
413
|
+
* holds placed after the item that precedes them there. The branch's order leads unless the
|
|
414
|
+
* branch kept the ancestor's order and the base did not, so a reorder on one side applies, and a
|
|
415
|
+
* reorder on both sides takes the branch's.
|
|
416
|
+
*/
|
|
417
|
+
export function mergeOrder(ancestorIds, baseIds, branchIds) {
|
|
418
|
+
const inBase = new Set(baseIds);
|
|
419
|
+
const inBranch = new Set(branchIds);
|
|
420
|
+
const inAncestor = new Set(ancestorIds);
|
|
421
|
+
const shared = (id) => inAncestor.has(id) && inBase.has(id) && inBranch.has(id);
|
|
422
|
+
const ancestorOrder = ancestorIds.filter(shared);
|
|
423
|
+
const reordered = (ids) => ids.filter(shared).some((id, n) => id !== ancestorOrder[n]);
|
|
424
|
+
const baseLeads = !reordered(branchIds) && reordered(baseIds);
|
|
425
|
+
const [lead, other] = baseLeads ? [baseIds, branchIds] : [branchIds, baseIds];
|
|
426
|
+
const inLead = new Set(lead);
|
|
427
|
+
const after = new Map();
|
|
428
|
+
let anchor;
|
|
429
|
+
for (const id of other) {
|
|
430
|
+
if (inLead.has(id)) {
|
|
431
|
+
anchor = id;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const run = after.get(anchor);
|
|
435
|
+
if (run)
|
|
436
|
+
run.push(id);
|
|
437
|
+
else
|
|
438
|
+
after.set(anchor, [id]);
|
|
439
|
+
}
|
|
440
|
+
return [...(after.get(undefined) ?? []), ...lead.flatMap((id) => [id, ...(after.get(id) ?? [])])];
|
|
441
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-level three-way merge of long text: a multi-line string, or an md/mdx body. Each side's
|
|
3
|
+
* changes apply where the other left the lines alone; lines both sides changed differently are a
|
|
4
|
+
* conflict region. Never writes conflict markers: in a region, the merged text holds the branch's
|
|
5
|
+
* lines, and the region records all three versions for the person who resolves it.
|
|
6
|
+
*/
|
|
7
|
+
/** Lines the base and the branch both changed, differently. */
|
|
8
|
+
export interface TextConflictRegion {
|
|
9
|
+
/** The region's lines in each version, joined by line breaks. */
|
|
10
|
+
ancestor: string;
|
|
11
|
+
base: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
/** Where the branch's lines sit in the merged text: the first line, 0-based, and how many. */
|
|
14
|
+
line: number;
|
|
15
|
+
lines: number;
|
|
16
|
+
baseLines: number;
|
|
17
|
+
}
|
|
18
|
+
export interface TextMergeResult {
|
|
19
|
+
text: string;
|
|
20
|
+
/** Empty when the merge is clean. */
|
|
21
|
+
regions: TextConflictRegion[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `base` and `branch`, both changed from `ancestor`, merged line by line. A line break at the very
|
|
25
|
+
* end, which a serializer may add or drop by itself, is merged on its own and never makes a
|
|
26
|
+
* conflict: the side that changed it wins, or the branch in a text with conflicts. Undefined when
|
|
27
|
+
* the texts are too far apart to diff in time: the caller then treats each version as one whole.
|
|
28
|
+
*/
|
|
29
|
+
export declare function mergeText(ancestor: string, base: string, branch: string): TextMergeResult | undefined;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-level three-way merge of long text: a multi-line string, or an md/mdx body. Each side's
|
|
3
|
+
* changes apply where the other left the lines alone; lines both sides changed differently are a
|
|
4
|
+
* conflict region. Never writes conflict markers: in a region, the merged text holds the branch's
|
|
5
|
+
* lines, and the region records all three versions for the person who resolves it.
|
|
6
|
+
*/
|
|
7
|
+
import { diff3Regions } from './diff3.js';
|
|
8
|
+
/** `text` as lines, its one line break at the very end apart. */
|
|
9
|
+
const split = (text) => {
|
|
10
|
+
const endBreak = text.endsWith('\n');
|
|
11
|
+
const rest = endBreak ? text.slice(0, -1) : text;
|
|
12
|
+
return { lines: rest === '' && !endBreak ? [] : rest.split('\n'), endBreak };
|
|
13
|
+
};
|
|
14
|
+
const sameLines = (x, y) => x.length === y.length && x.every((line, i) => line === y[i]);
|
|
15
|
+
/**
|
|
16
|
+
* `base` and `branch`, both changed from `ancestor`, merged line by line. A line break at the very
|
|
17
|
+
* end, which a serializer may add or drop by itself, is merged on its own and never makes a
|
|
18
|
+
* conflict: the side that changed it wins, or the branch in a text with conflicts. Undefined when
|
|
19
|
+
* the texts are too far apart to diff in time: the caller then treats each version as one whole.
|
|
20
|
+
*/
|
|
21
|
+
export function mergeText(ancestor, base, branch) {
|
|
22
|
+
const o = split(ancestor);
|
|
23
|
+
const a = split(base);
|
|
24
|
+
const b = split(branch);
|
|
25
|
+
const regions = diff3Regions(o.lines, a.lines, b.lines);
|
|
26
|
+
if (!regions)
|
|
27
|
+
return undefined;
|
|
28
|
+
const versions = { ancestor: o.lines, base: a.lines, branch: b.lines };
|
|
29
|
+
const out = [];
|
|
30
|
+
const conflicts = [];
|
|
31
|
+
for (const region of regions) {
|
|
32
|
+
if (region.stable) {
|
|
33
|
+
out.push(...versions[region.side].slice(...region.span));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const [oLines, aLines, bLines] = [
|
|
37
|
+
o.lines.slice(...region.ancestor),
|
|
38
|
+
a.lines.slice(...region.base),
|
|
39
|
+
b.lines.slice(...region.branch),
|
|
40
|
+
];
|
|
41
|
+
if (sameLines(aLines, bLines)) {
|
|
42
|
+
out.push(...bLines);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
conflicts.push({
|
|
46
|
+
ancestor: oLines.join('\n'),
|
|
47
|
+
base: aLines.join('\n'),
|
|
48
|
+
branch: bLines.join('\n'),
|
|
49
|
+
line: out.length,
|
|
50
|
+
lines: bLines.length,
|
|
51
|
+
baseLines: aLines.length,
|
|
52
|
+
});
|
|
53
|
+
out.push(...bLines);
|
|
54
|
+
}
|
|
55
|
+
// A conflicted text holds the branch's lines, so it holds the branch's end too.
|
|
56
|
+
const endBreak = conflicts.length > 0 || o.endBreak === a.endBreak ? b.endBreak : a.endBreak;
|
|
57
|
+
const body = out.join('\n');
|
|
58
|
+
return { text: endBreak ? `${body}\n` : body, regions: conflicts };
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When two content values count as the same, for every comparison of entry versions: the "Show
|
|
3
|
+
* changes" drawer (`editor/entry-diff.ts`) and the field merge (`entry-merge.ts`) both use it, so a
|
|
4
|
+
* value the drawer calls unchanged is one the merge never calls a conflict.
|
|
5
|
+
*/
|
|
6
|
+
/** Absent, null, `''`, `[]`, or a record holding nothing but those and an item key. */
|
|
7
|
+
export declare const isEmptyValue: (value: unknown) => boolean;
|
|
8
|
+
/** `value` without item keys and empty members, so equal content compares equal. */
|
|
9
|
+
export declare const normalizedValue: (value: unknown) => unknown;
|
|
10
|
+
/** A string naming `value` exactly: equal for two values exactly when they hold the same. */
|
|
11
|
+
export declare const exactToken: (value: unknown) => string;
|
|
12
|
+
/**
|
|
13
|
+
* A string naming `value` up to {@link sameValue}: two values share a token exactly when they are
|
|
14
|
+
* the same, so a sequence of values can be aligned as a sequence of strings.
|
|
15
|
+
*/
|
|
16
|
+
export declare const valueToken: (value: unknown) => string;
|
|
17
|
+
/** The same content: item keys and empty members aside, and every empty value alike. */
|
|
18
|
+
export declare const sameValue: (a: unknown, b: unknown) => boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When two content values count as the same, for every comparison of entry versions: the "Show
|
|
3
|
+
* changes" drawer (`editor/entry-diff.ts`) and the field merge (`entry-merge.ts`) both use it, so a
|
|
4
|
+
* value the drawer calls unchanged is one the merge never calls a conflict.
|
|
5
|
+
*/
|
|
6
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
7
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
8
|
+
/** `value`'s own members, `__proto__` included as a member like any other. */
|
|
9
|
+
const members = (value) => Object.entries(value);
|
|
10
|
+
/** Absent, null, `''`, `[]`, or a record holding nothing but those and an item key. */
|
|
11
|
+
export const isEmptyValue = (value) => value === undefined ||
|
|
12
|
+
value === null ||
|
|
13
|
+
value === '' ||
|
|
14
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
15
|
+
(isPlainRecord(value) && members(value).every(([k, v]) => k === ITEM_KEY || isEmptyValue(v)));
|
|
16
|
+
/** `value` without item keys and empty members, so equal content compares equal. */
|
|
17
|
+
export const normalizedValue = (value) => {
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value.map(normalizedValue);
|
|
20
|
+
if (!isPlainRecord(value))
|
|
21
|
+
return value;
|
|
22
|
+
return Object.fromEntries(members(value)
|
|
23
|
+
.filter(([k, v]) => k !== ITEM_KEY && !isEmptyValue(v))
|
|
24
|
+
.map(([k, v]) => [k, normalizedValue(v)]));
|
|
25
|
+
};
|
|
26
|
+
/** A string naming `value` exactly: equal for two values exactly when they hold the same. */
|
|
27
|
+
export const exactToken = (value) => {
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return `[${value.map(exactToken).join(',')}]`;
|
|
30
|
+
if (value instanceof Date)
|
|
31
|
+
return `D${value.getTime()}`;
|
|
32
|
+
if (isPlainRecord(value)) {
|
|
33
|
+
const sorted = members(value).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
|
|
34
|
+
return `{${sorted.map(([k, v]) => `${JSON.stringify(k)}:${exactToken(v)}`).join(',')}}`;
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'string')
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
if (typeof value === 'number')
|
|
39
|
+
return Object.is(value, -0) ? '0' : String(value);
|
|
40
|
+
if (typeof value === 'bigint')
|
|
41
|
+
return `${value}n`;
|
|
42
|
+
return String(value);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A string naming `value` up to {@link sameValue}: two values share a token exactly when they are
|
|
46
|
+
* the same, so a sequence of values can be aligned as a sequence of strings.
|
|
47
|
+
*/
|
|
48
|
+
export const valueToken = (value) => isEmptyValue(value) ? '' : exactToken(normalizedValue(value));
|
|
49
|
+
/** The same content: item keys and empty members aside, and every empty value alike. */
|
|
50
|
+
export const sameValue = (a, b) => valueToken(a) === valueToken(b);
|