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,55 @@
|
|
|
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}) unless an item here holds that
|
|
27
|
+
* item's own key or the ancestor's read gave it to an item of another list, is that ancestor item; any
|
|
28
|
+
* other key marks the item new when it was minted for it, the ancestor knows it (a copy), or the
|
|
29
|
+
* ancestor's read gave it to an item of another list (`inEntry`, every key the ancestor's read
|
|
30
|
+
* gives across the entry): that item moved here.
|
|
31
|
+
*
|
|
32
|
+
* The rest align as diff3 aligns lines: equal content in order, then equal content out of order
|
|
33
|
+
* (a move), then, in each gap between two matches, by shared values and last by place
|
|
34
|
+
* ({@link pairInGap}). `otherSide` is the other version's list: its copy of an item counts here
|
|
35
|
+
* where it holds the same rewrite.
|
|
36
|
+
* Undefined when the diff ran out of time.
|
|
37
|
+
*/
|
|
38
|
+
export declare function alignToAncestor(items: readonly unknown[], ancestorItems: readonly unknown[], ancestorKeys: readonly string[], side: string, inEntry: ReadonlySet<string>, movedIn: ReadonlySet<string>, otherSide?: readonly unknown[]): Alignment | undefined;
|
|
39
|
+
/** True for an identity {@link alignToAncestor} gave an item new on its side. */
|
|
40
|
+
export declare const isNewIdentity: (id: string) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Pairs the items new on the base with the items new on the branch that are the same addition:
|
|
43
|
+
* the same key, else equal content, in order. The branch's identity replaces the base's in
|
|
44
|
+
* `baseIds` for each pair, so an item both sides added alike is one item; one moved in from another
|
|
45
|
+
* list (`movedHere`) is no addition, and pairs by key only. Pairing is skipped by content when the
|
|
46
|
+
* diff runs out of time: the additions stay apart, and none is lost.
|
|
47
|
+
*/
|
|
48
|
+
export declare function pairNewItems(baseItems: readonly unknown[], baseIds: string[], branchItems: readonly unknown[], branchIds: readonly string[], movedHere: (key: string) => boolean): void;
|
|
49
|
+
/**
|
|
50
|
+
* The order of the merged list's identities: one side's order, with the items only the other side
|
|
51
|
+
* holds placed after the item that precedes them there. The branch's order leads unless the
|
|
52
|
+
* branch kept the ancestor's order and the base did not, so a reorder on one side applies, and a
|
|
53
|
+
* reorder on both sides takes the branch's.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mergeOrder(ancestorIds: readonly string[], baseIds: readonly string[], branchIds: readonly string[]): string[];
|
|
@@ -0,0 +1,490 @@
|
|
|
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}) unless an item here holds that
|
|
69
|
+
* item's own key or the ancestor's read gave it to an item of another list, is that ancestor item; any
|
|
70
|
+
* other key marks the item new when it was minted for it, the ancestor knows it (a copy), or the
|
|
71
|
+
* ancestor's read gave it to an item of another list (`inEntry`, every key the ancestor's read
|
|
72
|
+
* gives across the entry): that item moved here.
|
|
73
|
+
*
|
|
74
|
+
* The rest align as diff3 aligns lines: equal content in order, then equal content out of order
|
|
75
|
+
* (a move), then, in each gap between two matches, by shared values and last by place
|
|
76
|
+
* ({@link pairInGap}). `otherSide` is the other version's list: its copy of an item counts here
|
|
77
|
+
* where it holds the same rewrite.
|
|
78
|
+
* Undefined when the diff ran out of time.
|
|
79
|
+
*/
|
|
80
|
+
export function alignToAncestor(items, ancestorItems, ancestorKeys, side, inEntry, movedIn, otherSide = []) {
|
|
81
|
+
const known = new Set(ancestorKeys);
|
|
82
|
+
const aliases = ancestorAliases(ancestorItems, ancestorKeys);
|
|
83
|
+
const readKeys = itemKeysAsRead(items);
|
|
84
|
+
const ids = items.map(() => undefined);
|
|
85
|
+
const taken = new Set();
|
|
86
|
+
const minted = new Set();
|
|
87
|
+
// Items whose key the ancestor no longer knows, by how they may still find their item.
|
|
88
|
+
const byContent = new Set();
|
|
89
|
+
const stale = new Set();
|
|
90
|
+
// A read keys an item by content (twins as K, K-2, ...), and a git-level merge bringing in the
|
|
91
|
+
// base's edit of one twin leaves its key on the edited copy, which the ancestor then reads, or a
|
|
92
|
+
// backfill keys, as another item's. So an item under a read key holding the item it names is
|
|
93
|
+
// that item, and one holding exactly another ancestor item no key on this side names is that
|
|
94
|
+
// one (an item a key names gives way only to one under its read key): also, with no git-level
|
|
95
|
+
// merge, an item a side edited to read exactly like one it deleted. A moved key names its item.
|
|
96
|
+
const held = new Set([...movedIn, ...items.flatMap((item) => ownKey(item) ?? [])]);
|
|
97
|
+
const unheld = new Map();
|
|
98
|
+
const unheldFresh = new Set();
|
|
99
|
+
ancestorItems.forEach((item, i) => {
|
|
100
|
+
if (held.has(ancestorKeys[i]))
|
|
101
|
+
return;
|
|
102
|
+
const token = valueToken(item);
|
|
103
|
+
unheld.set(token, (unheld.get(token) ?? 0) + 1);
|
|
104
|
+
if (!isMinted(item))
|
|
105
|
+
unheldFresh.add(token);
|
|
106
|
+
});
|
|
107
|
+
const at = new Map(ancestorKeys.map((key, i) => [key, i]));
|
|
108
|
+
// Content a copy holds, under a key minted for it that the ancestor doesn't know.
|
|
109
|
+
const copies = (list) => new Set(list.flatMap((item) => {
|
|
110
|
+
const own = ownKey(item);
|
|
111
|
+
return own !== undefined && !hasContentKeyShape(own) && !known.has(own)
|
|
112
|
+
? [valueToken(item)]
|
|
113
|
+
: [];
|
|
114
|
+
}));
|
|
115
|
+
const [copied, otherCopied] = [copies(items), copies(otherSide)];
|
|
116
|
+
// The other side's copy counts where it holds the same rewrite, so both read that item alike.
|
|
117
|
+
// A key is compared by the ancestor item it names, so an alias matches its read key.
|
|
118
|
+
const names = (key) => key === undefined || known.has(key) ? key : (aliases.get(key) ?? key);
|
|
119
|
+
const otherHolds = new Set(otherSide.map((item) => `${names(ownKey(item))}\u0000${valueToken(item)}`));
|
|
120
|
+
const sameRewrite = (j, own) => otherHolds.has(`${names(own)}\u0000${valueToken(items[j])}`);
|
|
121
|
+
// Whether the item may be the unheld item holding its content: `named` when its key names
|
|
122
|
+
// another, `keyed` when a copy also shows that one was rewritten, `unnamed` when it names none.
|
|
123
|
+
const claim = (j, own) => {
|
|
124
|
+
if (!hasContentKeyShape(own))
|
|
125
|
+
return undefined;
|
|
126
|
+
const content = valueToken(items[j]);
|
|
127
|
+
const holders = unheld.get(content) ?? 0;
|
|
128
|
+
if (holders === 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
const read = readKeys[j];
|
|
131
|
+
const named = read !== undefined && known.has(read) ? read : aliases.get(own);
|
|
132
|
+
const i = named === undefined ? undefined : at.get(named);
|
|
133
|
+
// A key that has gone stale on both sides is history, not content: it names its item.
|
|
134
|
+
if (i !== undefined) {
|
|
135
|
+
if (isMinted(ancestorItems[i]) && isMinted(items[j]))
|
|
136
|
+
return undefined;
|
|
137
|
+
// A key takes only an unheld item still under its read key: one gone stale was edited.
|
|
138
|
+
if (!unheldFresh.has(content))
|
|
139
|
+
return undefined;
|
|
140
|
+
// With a copy of the item its key names beside it, the side rewrote that item: read by key.
|
|
141
|
+
const was = valueToken(ancestorItems[i]);
|
|
142
|
+
if (was === content)
|
|
143
|
+
return undefined;
|
|
144
|
+
return copied.has(was) || (otherCopied.has(was) && sameRewrite(j, own)) ? 'keyed' : 'named';
|
|
145
|
+
}
|
|
146
|
+
// A key naming no item here pairs by content only where one item holds it, twins by place.
|
|
147
|
+
return holders === 1 ? 'unnamed' : undefined;
|
|
148
|
+
};
|
|
149
|
+
const claims = items.map((item, j) => {
|
|
150
|
+
const own = ownKey(item);
|
|
151
|
+
return own === undefined ? undefined : claim(j, own);
|
|
152
|
+
});
|
|
153
|
+
const claimants = new Map();
|
|
154
|
+
claims.forEach((c, j) => {
|
|
155
|
+
const content = valueToken(items[j]);
|
|
156
|
+
if (c !== undefined)
|
|
157
|
+
claimants.set(content, (claimants.get(content) ?? 0) + 1);
|
|
158
|
+
});
|
|
159
|
+
// More claimants than holders can't all be those items: one a key names is that item instead.
|
|
160
|
+
const usurped = (j) => {
|
|
161
|
+
const content = valueToken(items[j]);
|
|
162
|
+
if (claims[j] !== 'named')
|
|
163
|
+
return claims[j] === 'unnamed';
|
|
164
|
+
return (claimants.get(content) ?? 0) <= (unheld.get(content) ?? 0);
|
|
165
|
+
};
|
|
166
|
+
const literal = new Set(items.flatMap((item) => ownKey(item) ?? []).filter((k) => known.has(k)));
|
|
167
|
+
const aliasOf = (own) => {
|
|
168
|
+
const id = inEntry.has(own) ? undefined : aliases.get(own);
|
|
169
|
+
return id !== undefined && !literal.has(id) ? id : undefined;
|
|
170
|
+
};
|
|
171
|
+
items.forEach((item, j) => {
|
|
172
|
+
const own = ownKey(item);
|
|
173
|
+
const read = readKeys[j];
|
|
174
|
+
if (own !== undefined && usurped(j)) {
|
|
175
|
+
minted.add(j);
|
|
176
|
+
byContent.add(j);
|
|
177
|
+
stale.add(j);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// A keyless item's read key is a hash of its content, which repeats among identical items:
|
|
181
|
+
// those align by content and order below, never by the hash.
|
|
182
|
+
const id = own === undefined
|
|
183
|
+
? undefined
|
|
184
|
+
: read !== undefined && known.has(read)
|
|
185
|
+
? read
|
|
186
|
+
: !known.has(own)
|
|
187
|
+
? aliasOf(own)
|
|
188
|
+
: undefined;
|
|
189
|
+
if (id !== undefined && !taken.has(id)) {
|
|
190
|
+
ids[j] = id;
|
|
191
|
+
taken.add(id);
|
|
192
|
+
}
|
|
193
|
+
else if (own !== undefined &&
|
|
194
|
+
read !== undefined &&
|
|
195
|
+
!known.has(own) &&
|
|
196
|
+
hasMachineKeyShape(own) &&
|
|
197
|
+
inEntry.has(read) &&
|
|
198
|
+
!(hasContentKeyShape(own) && (unheld.get(valueToken(item)) ?? 0) > 0)) {
|
|
199
|
+
// An ancestor item of another list, moved here with its key: new to this list. Not under a
|
|
200
|
+
// read key holding exactly an item of this list no key here names: a reworded twin.
|
|
201
|
+
minted.add(j);
|
|
202
|
+
}
|
|
203
|
+
else if (own !== undefined && !known.has(own) && isMinted(item)) {
|
|
204
|
+
// A key the ancestor doesn't know: minted for a new item, stripped there by hand, or a read
|
|
205
|
+
// key for content a sync has since replaced with the base's edit (`stale`). See below.
|
|
206
|
+
minted.add(j);
|
|
207
|
+
byContent.add(j);
|
|
208
|
+
if (hasContentKeyShape(own))
|
|
209
|
+
stale.add(j);
|
|
210
|
+
}
|
|
211
|
+
else if (own !== undefined && (known.has(own) || isMinted(item))) {
|
|
212
|
+
minted.add(j);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const match = (j, i) => {
|
|
216
|
+
ids[j] = ancestorKeys[i];
|
|
217
|
+
taken.add(ancestorKeys[i]);
|
|
218
|
+
};
|
|
219
|
+
// Items matched by key anchor the alignment too, where they kept their order.
|
|
220
|
+
const ancestorTokens = ancestorItems.map((item, i) => taken.has(ancestorKeys[i]) ? `${INTERNAL}k${ancestorKeys[i]}` : `c${valueToken(item)}`);
|
|
221
|
+
const sideTokens = items.map((item, j) => {
|
|
222
|
+
const id = ids[j];
|
|
223
|
+
if (minted.has(j))
|
|
224
|
+
return `${INTERNAL}m${j}`;
|
|
225
|
+
return id !== undefined ? `${INTERNAL}k${id}` : `c${valueToken(item)}`;
|
|
226
|
+
});
|
|
227
|
+
const anchors = commonPairs(ancestorTokens, sideTokens);
|
|
228
|
+
if (!anchors)
|
|
229
|
+
return undefined;
|
|
230
|
+
for (const [i, j] of anchors)
|
|
231
|
+
if (ids[j] === undefined)
|
|
232
|
+
match(j, i);
|
|
233
|
+
// An item whose content an unmatched ancestor item has, out of order: it was moved.
|
|
234
|
+
const unmatched = new Map();
|
|
235
|
+
ancestorItems.forEach((item, i) => {
|
|
236
|
+
if (taken.has(ancestorKeys[i]))
|
|
237
|
+
return;
|
|
238
|
+
const token = valueToken(item);
|
|
239
|
+
unmatched.set(token, [...(unmatched.get(token) ?? []), i]);
|
|
240
|
+
});
|
|
241
|
+
items.forEach((item, j) => {
|
|
242
|
+
if (ids[j] !== undefined || minted.has(j))
|
|
243
|
+
return;
|
|
244
|
+
const i = unmatched.get(valueToken(item))?.shift();
|
|
245
|
+
if (i !== undefined)
|
|
246
|
+
match(j, i);
|
|
247
|
+
});
|
|
248
|
+
// Such an item is the ancestor item holding its content, or for a stale key the one holding
|
|
249
|
+
// most of its values; never one under a key minted for it, though one under a read key gone
|
|
250
|
+
// stale may. Only a stale key may still pair by place.
|
|
251
|
+
const candidates = ancestorItems.flatMap((item, i) => taken.has(ancestorKeys[i]) || (isMinted(item) && !hasContentKeyShape(ancestorKeys[i]))
|
|
252
|
+
? []
|
|
253
|
+
: [i]);
|
|
254
|
+
const open = (i) => !taken.has(ancestorKeys[i]);
|
|
255
|
+
for (const j of byContent) {
|
|
256
|
+
const i = candidates.find((k) => open(k) && valueToken(ancestorItems[k]) === valueToken(items[j]));
|
|
257
|
+
if (i !== undefined)
|
|
258
|
+
match(j, i);
|
|
259
|
+
}
|
|
260
|
+
// By values only with one clear match holding most of what it held, and never for content an
|
|
261
|
+
// item of another list holds exactly: that item moved here.
|
|
262
|
+
const byValues = valueIndex(candidates, ancestorItems);
|
|
263
|
+
for (const j of stale) {
|
|
264
|
+
if (ids[j] !== undefined)
|
|
265
|
+
continue;
|
|
266
|
+
const content = itemKeysAsRead([withoutOwnKey(items[j])])[0];
|
|
267
|
+
if (content !== undefined && !known.has(content) && inEntry.has(content)) {
|
|
268
|
+
stale.delete(j);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const i = byValues.clear(items[j], open);
|
|
272
|
+
if (i !== undefined)
|
|
273
|
+
match(j, i);
|
|
274
|
+
}
|
|
275
|
+
const bounds = [
|
|
276
|
+
[-1, -1],
|
|
277
|
+
...anchors,
|
|
278
|
+
[ancestorItems.length, items.length],
|
|
279
|
+
];
|
|
280
|
+
const byPlace = new Set();
|
|
281
|
+
for (let n = 0; n + 1 < bounds.length; n++) {
|
|
282
|
+
const ancestorGap = [];
|
|
283
|
+
const sideGap = [];
|
|
284
|
+
for (let i = bounds[n][0] + 1; i < bounds[n + 1][0]; i++) {
|
|
285
|
+
if (!taken.has(ancestorKeys[i]))
|
|
286
|
+
ancestorGap.push(i);
|
|
287
|
+
}
|
|
288
|
+
for (let j = bounds[n][1] + 1; j < bounds[n + 1][1]; j++) {
|
|
289
|
+
if (ids[j] === undefined && (!minted.has(j) || stale.has(j)))
|
|
290
|
+
sideGap.push(j);
|
|
291
|
+
}
|
|
292
|
+
// A keyless item holding an ancestor item of another list exactly may have moved here: it
|
|
293
|
+
// pairs only by its values, never by place.
|
|
294
|
+
const movedHere = (j) => {
|
|
295
|
+
const read = readKeys[j];
|
|
296
|
+
return read !== undefined && !known.has(read) && inEntry.has(read);
|
|
297
|
+
};
|
|
298
|
+
// An item under a stale key existed before, so it may still pair by place, never by values.
|
|
299
|
+
const placeOnly = (j) => stale.has(j);
|
|
300
|
+
const paired = pairInGap(sideGap, items, ancestorGap, ancestorItems, movedHere, placeOnly);
|
|
301
|
+
for (const [j, i, place] of paired) {
|
|
302
|
+
match(j, i);
|
|
303
|
+
if (place)
|
|
304
|
+
byPlace.add(j);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// An ancestor item under a stale key of its own is one a side may hold under another.
|
|
308
|
+
const unmatched_ = ancestorItems.some((item, i) => !taken.has(ancestorKeys[i]) && (!isMinted(item) || hasContentKeyShape(ancestorKeys[i])));
|
|
309
|
+
const unsure = unmatched_ && [...stale].some((j) => ids[j] === undefined);
|
|
310
|
+
return { ids: ids.map((id, j) => id ?? newIdentity(side, j)), byPlace, unsure };
|
|
311
|
+
}
|
|
312
|
+
/** Above this many value matches counted in one gap, its items pair by place or not at all. */
|
|
313
|
+
const MAX_SCORE_STEPS = 1_000_000;
|
|
314
|
+
/** `item`'s content members: a block's data inside its `value`, never its key or template. */
|
|
315
|
+
const contentMembers = (item) => {
|
|
316
|
+
if (!isPlainRecord(item))
|
|
317
|
+
return [];
|
|
318
|
+
const data = typeof item.template === 'string' && isPlainRecord(item.value) ? item.value : item;
|
|
319
|
+
return Object.entries(data).filter(([name, value]) => name !== ITEM_KEY && !isBlockStructuralKey(name) && !isEmptyValue(value));
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Pairs a gap's side items with its ancestor items: each with the one holding most of its values,
|
|
323
|
+
* the earliest on a tie. When as many are left on both sides, they pair in order by place (`true`):
|
|
324
|
+
* only keyless items that did not come from another list (`movedHere`), and only with ancestor
|
|
325
|
+
* items not carrying a key minted for them. The rest are adds and removes, so a delete and an add
|
|
326
|
+
* in one place never read as an edit.
|
|
327
|
+
*/
|
|
328
|
+
function pairInGap(sideGap, items, ancestorGap, ancestorItems, movedHere, placeOnly) {
|
|
329
|
+
const index = valueIndex(ancestorGap, ancestorItems);
|
|
330
|
+
const free = new Set(ancestorGap);
|
|
331
|
+
const pairs = [];
|
|
332
|
+
const unpaired = [];
|
|
333
|
+
for (const j of sideGap) {
|
|
334
|
+
if (placeOnly(j)) {
|
|
335
|
+
unpaired.push(j);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const best = index.best(items[j], (i) => free.has(i));
|
|
339
|
+
if (index.overBudget())
|
|
340
|
+
return byPlace(sideGap, ancestorGap, items, ancestorItems);
|
|
341
|
+
if (best === undefined) {
|
|
342
|
+
// An item with a key of its own that keeps no value of any is not one of them.
|
|
343
|
+
if (ownKey(items[j]) === undefined && !movedHere(j))
|
|
344
|
+
unpaired.push(j);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
free.delete(best);
|
|
348
|
+
pairs.push([j, best, false]);
|
|
349
|
+
}
|
|
350
|
+
// An ancestor item with a key minted for it is that item only under the key.
|
|
351
|
+
const left = [...free].filter((i) => !isMinted(ancestorItems[i])).sort((x, y) => x - y);
|
|
352
|
+
if (left.length === unpaired.length)
|
|
353
|
+
unpaired.forEach((j, k) => pairs.push([j, left[k], true]));
|
|
354
|
+
return pairs;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The ancestor items at `indices` indexed by their member values: `best(item, open)` is the open
|
|
358
|
+
* one holding most of `item`'s values, the earliest on a tie, or undefined when none holds any.
|
|
359
|
+
* Each value `best` matches counts against {@link MAX_SCORE_STEPS}, past which `overBudget()` is
|
|
360
|
+
* true.
|
|
361
|
+
*/
|
|
362
|
+
function valueIndex(indices, ancestorItems) {
|
|
363
|
+
const holders = new Map();
|
|
364
|
+
for (const i of indices) {
|
|
365
|
+
for (const [name, value] of contentMembers(ancestorItems[i])) {
|
|
366
|
+
const at = `${name}\u0000${valueToken(value)}`;
|
|
367
|
+
holders.set(at, [...(holders.get(at) ?? []), i]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
let steps = 0;
|
|
371
|
+
return {
|
|
372
|
+
best(item, open) {
|
|
373
|
+
const scores = new Map();
|
|
374
|
+
for (const [name, value] of contentMembers(item)) {
|
|
375
|
+
for (const i of holders.get(`${name}\u0000${valueToken(value)}`) ?? []) {
|
|
376
|
+
steps++;
|
|
377
|
+
if (open(i))
|
|
378
|
+
scores.set(i, (scores.get(i) ?? 0) + 1);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
let best;
|
|
382
|
+
let bestScore = 0;
|
|
383
|
+
for (const [i, score] of scores) {
|
|
384
|
+
if (score > bestScore || (score === bestScore && best !== undefined && i < best)) {
|
|
385
|
+
;
|
|
386
|
+
[best, bestScore] = [i, score];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return best;
|
|
390
|
+
},
|
|
391
|
+
/** `best`, when no other open item holds as many, and those are over half of its own values. */
|
|
392
|
+
clear(item, open) {
|
|
393
|
+
const scores = new Map();
|
|
394
|
+
for (const [name, value] of contentMembers(item)) {
|
|
395
|
+
for (const i of holders.get(`${name}\u0000${valueToken(value)}`) ?? []) {
|
|
396
|
+
if (open(i))
|
|
397
|
+
scores.set(i, (scores.get(i) ?? 0) + 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const ranked = [...scores].sort(([, x], [, y]) => y - x);
|
|
401
|
+
const [first, second] = ranked;
|
|
402
|
+
if (!first || (second && second[1] === first[1]))
|
|
403
|
+
return undefined;
|
|
404
|
+
return first[1] * 2 > contentMembers(ancestorItems[first[0]]).length ? first[0] : undefined;
|
|
405
|
+
},
|
|
406
|
+
overBudget: () => steps > MAX_SCORE_STEPS,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/** A gap too large to score: pairs as `pairInGap`'s by-place step does, from the start. */
|
|
410
|
+
function byPlace(sideGap, ancestorGap, items, ancestorItems) {
|
|
411
|
+
const side = sideGap.filter((j) => ownKey(items[j]) === undefined);
|
|
412
|
+
const ancestor = ancestorGap.filter((i) => !isMinted(ancestorItems[i]));
|
|
413
|
+
return side.length === ancestor.length ? side.map((j, k) => [j, ancestor[k], true]) : [];
|
|
414
|
+
}
|
|
415
|
+
/** The identity of the `j`th item of `side`'s list, new on that side. */
|
|
416
|
+
const newIdentity = (side, j) => `${INTERNAL}${side}${j}`;
|
|
417
|
+
/** True for an identity {@link alignToAncestor} gave an item new on its side. */
|
|
418
|
+
export const isNewIdentity = (id) => id.startsWith(INTERNAL);
|
|
419
|
+
/**
|
|
420
|
+
* Pairs the items new on the base with the items new on the branch that are the same addition:
|
|
421
|
+
* the same key, else equal content, in order. The branch's identity replaces the base's in
|
|
422
|
+
* `baseIds` for each pair, so an item both sides added alike is one item; one moved in from another
|
|
423
|
+
* list (`movedHere`) is no addition, and pairs by key only. Pairing is skipped by content when the
|
|
424
|
+
* diff runs out of time: the additions stay apart, and none is lost.
|
|
425
|
+
*/
|
|
426
|
+
export function pairNewItems(baseItems, baseIds, branchItems, branchIds, movedHere) {
|
|
427
|
+
const baseNew = baseIds.flatMap((id, j) => (isNewIdentity(id) ? [j] : []));
|
|
428
|
+
const branchNew = branchIds.flatMap((id, j) => (isNewIdentity(id) ? [j] : []));
|
|
429
|
+
const pairedBase = new Set();
|
|
430
|
+
const pairedBranch = new Set();
|
|
431
|
+
// The same key as a read gives it: a key both added, or a repeat both added alike.
|
|
432
|
+
const [baseRead, branchRead] = [itemKeysAsRead(baseItems), itemKeysAsRead(branchItems)];
|
|
433
|
+
const branchByKey = new Map();
|
|
434
|
+
for (const j of branchNew) {
|
|
435
|
+
const key = branchRead[j];
|
|
436
|
+
if (key !== undefined && !branchByKey.has(key))
|
|
437
|
+
branchByKey.set(key, j);
|
|
438
|
+
}
|
|
439
|
+
for (const j of baseNew) {
|
|
440
|
+
const key = baseRead[j];
|
|
441
|
+
const match = key === undefined ? undefined : branchByKey.get(key);
|
|
442
|
+
if (match !== undefined && !pairedBranch.has(match)) {
|
|
443
|
+
baseIds[j] = branchIds[match];
|
|
444
|
+
pairedBase.add(j);
|
|
445
|
+
pairedBranch.add(match);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Equal content pairs two additions, unless both carry minted keys: two items reading alike.
|
|
449
|
+
const restBase = baseNew.filter((j) => !pairedBase.has(j));
|
|
450
|
+
const restBranch = branchNew.filter((j) => !pairedBranch.has(j));
|
|
451
|
+
const pairs = (commonPairs(restBase.map((j) => valueToken(baseItems[j])), restBranch.map((j) => valueToken(branchItems[j]))) ?? []).filter(([x, y]) => {
|
|
452
|
+
const [a, b] = [baseItems[restBase[x]], branchItems[restBranch[y]]];
|
|
453
|
+
const [ka, kb] = [ownKey(a), ownKey(b)];
|
|
454
|
+
const movedIn = (k) => k !== undefined && movedHere(k);
|
|
455
|
+
return !(isMinted(a) && isMinted(b)) && !(ka !== kb && (movedIn(ka) || movedIn(kb)));
|
|
456
|
+
});
|
|
457
|
+
for (const [x, y] of pairs)
|
|
458
|
+
baseIds[restBase[x]] = branchIds[restBranch[y]];
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The order of the merged list's identities: one side's order, with the items only the other side
|
|
462
|
+
* holds placed after the item that precedes them there. The branch's order leads unless the
|
|
463
|
+
* branch kept the ancestor's order and the base did not, so a reorder on one side applies, and a
|
|
464
|
+
* reorder on both sides takes the branch's.
|
|
465
|
+
*/
|
|
466
|
+
export function mergeOrder(ancestorIds, baseIds, branchIds) {
|
|
467
|
+
const inBase = new Set(baseIds);
|
|
468
|
+
const inBranch = new Set(branchIds);
|
|
469
|
+
const inAncestor = new Set(ancestorIds);
|
|
470
|
+
const shared = (id) => inAncestor.has(id) && inBase.has(id) && inBranch.has(id);
|
|
471
|
+
const ancestorOrder = ancestorIds.filter(shared);
|
|
472
|
+
const reordered = (ids) => ids.filter(shared).some((id, n) => id !== ancestorOrder[n]);
|
|
473
|
+
const baseLeads = !reordered(branchIds) && reordered(baseIds);
|
|
474
|
+
const [lead, other] = baseLeads ? [baseIds, branchIds] : [branchIds, baseIds];
|
|
475
|
+
const inLead = new Set(lead);
|
|
476
|
+
const after = new Map();
|
|
477
|
+
let anchor;
|
|
478
|
+
for (const id of other) {
|
|
479
|
+
if (inLead.has(id)) {
|
|
480
|
+
anchor = id;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const run = after.get(anchor);
|
|
484
|
+
if (run)
|
|
485
|
+
run.push(id);
|
|
486
|
+
else
|
|
487
|
+
after.set(anchor, [id]);
|
|
488
|
+
}
|
|
489
|
+
return [...(after.get(undefined) ?? []), ...lead.flatMap((id) => [id, ...(after.get(id) ?? [])])];
|
|
490
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field merge's keys, onto what a git-level merge wrote: git keeps each item under its side's
|
|
3
|
+
* key, which can name another item once the other side changed it.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* `merged` with each list item under the key `fieldMerged` gives the item holding its content, in
|
|
7
|
+
* `merged`'s order, alike items matched in order; nested lists alike. Undefined where the two don't
|
|
8
|
+
* hold the same content, item keys and the order of list items aside. A list of plain values is
|
|
9
|
+
* content in its order.
|
|
10
|
+
*/
|
|
11
|
+
export declare function rekeyAsMerged(merged: unknown, fieldMerged: unknown): unknown;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field merge's keys, onto what a git-level merge wrote: git keeps each item under its side's
|
|
3
|
+
* key, which can name another item once the other side changed it.
|
|
4
|
+
*/
|
|
5
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
6
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
7
|
+
import { isEmptyValue, valueToken } from './value-equality.js';
|
|
8
|
+
const owns = (record, name) => Object.prototype.hasOwnProperty.call(record, name);
|
|
9
|
+
const isRecordList = (value) => Array.isArray(value) && value.length > 0 && value.every(isPlainRecord);
|
|
10
|
+
/** `value`'s content with item keys aside and each list of records in any order. */
|
|
11
|
+
const looseToken = (value) => {
|
|
12
|
+
if (isEmptyValue(value))
|
|
13
|
+
return '';
|
|
14
|
+
if (isRecordList(value))
|
|
15
|
+
return `[${value.map(looseToken).sort().join(',')}]`;
|
|
16
|
+
if (!isPlainRecord(value))
|
|
17
|
+
return valueToken(value);
|
|
18
|
+
const members = Object.entries(value)
|
|
19
|
+
.filter(([name, v]) => name !== ITEM_KEY && !isEmptyValue(v))
|
|
20
|
+
.sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
|
|
21
|
+
return `{${members.map(([name, v]) => `${JSON.stringify(name)}:${looseToken(v)}`).join(',')}}`;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* `merged` with each list item under the key `fieldMerged` gives the item holding its content, in
|
|
25
|
+
* `merged`'s order, alike items matched in order; nested lists alike. Undefined where the two don't
|
|
26
|
+
* hold the same content, item keys and the order of list items aside. A list of plain values is
|
|
27
|
+
* content in its order.
|
|
28
|
+
*/
|
|
29
|
+
export function rekeyAsMerged(merged, fieldMerged) {
|
|
30
|
+
if (isRecordList(merged) && isRecordList(fieldMerged)) {
|
|
31
|
+
if (merged.length !== fieldMerged.length)
|
|
32
|
+
return undefined;
|
|
33
|
+
const pool = fieldMerged.map((item) => ({ item, token: looseToken(item) }));
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const item of merged) {
|
|
36
|
+
const token = looseToken(item);
|
|
37
|
+
const at = pool.findIndex((p) => p.token === token);
|
|
38
|
+
if (at === -1)
|
|
39
|
+
return undefined;
|
|
40
|
+
const [{ item: match }] = pool.splice(at, 1);
|
|
41
|
+
const rekeyed = rekeyAsMerged(item, match);
|
|
42
|
+
if (rekeyed === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
out.push(rekeyed);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
if (isPlainRecord(merged) && isPlainRecord(fieldMerged)) {
|
|
49
|
+
// Git's members in git's order, so the text changes only where a key does.
|
|
50
|
+
const out = [];
|
|
51
|
+
const key = owns(fieldMerged, ITEM_KEY) ? fieldMerged[ITEM_KEY] : undefined;
|
|
52
|
+
for (const [name, value] of Object.entries(merged)) {
|
|
53
|
+
if (name === ITEM_KEY) {
|
|
54
|
+
if (key !== undefined)
|
|
55
|
+
out.push([name, key]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const other = owns(fieldMerged, name) ? fieldMerged[name] : undefined;
|
|
59
|
+
const rekeyed = isEmptyValue(value) && isEmptyValue(other) ? value : rekeyAsMerged(value, other);
|
|
60
|
+
if (rekeyed === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
out.push([name, rekeyed]);
|
|
63
|
+
}
|
|
64
|
+
for (const [name, value] of Object.entries(fieldMerged)) {
|
|
65
|
+
if (owns(merged, name))
|
|
66
|
+
continue;
|
|
67
|
+
if (name === ITEM_KEY)
|
|
68
|
+
out.push([name, value]);
|
|
69
|
+
else if (!isEmptyValue(value))
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return Object.fromEntries(out);
|
|
73
|
+
}
|
|
74
|
+
return valueToken(merged) === valueToken(fieldMerged) ? merged : undefined;
|
|
75
|
+
}
|
|
@@ -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;
|