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
|
@@ -187,12 +187,19 @@ export function useDraftManager(options) {
|
|
|
187
187
|
//
|
|
188
188
|
// 3. The comparison is `fast-deep-equal`, so key insertion order of a
|
|
189
189
|
// rehydrated draft does not make it dirty.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
//
|
|
191
|
+
// 4. An entry with a conflict choice to send counts, draft or not (`pendingIds`).
|
|
192
|
+
const pendingIds = options.pendingIds;
|
|
193
|
+
const dirtyIds = useMemo(() => {
|
|
194
|
+
const dirty = Object.keys(drafts).filter((id) => {
|
|
195
|
+
if (entriesKnown && !entryById.has(id))
|
|
196
|
+
return false;
|
|
197
|
+
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
198
|
+
return baseline === undefined || !equal(drafts[id], baseline);
|
|
199
|
+
});
|
|
200
|
+
const pending = [...(pendingIds ?? [])].filter((id) => !dirty.includes(id) && (!entriesKnown || entryById.has(id)));
|
|
201
|
+
return pending.length > 0 ? [...dirty, ...pending] : dirty;
|
|
202
|
+
}, [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById, pendingIds]);
|
|
196
203
|
const modifiedCount = dirtyIds.length;
|
|
197
204
|
const editedFiles = useMemo(() => dirtyIds
|
|
198
205
|
.map((id) => {
|
|
@@ -963,7 +970,10 @@ export function useDraftManager(options) {
|
|
|
963
970
|
*/
|
|
964
971
|
const saveOneOfAll = async (entry, branch) => {
|
|
965
972
|
const id = entry.contentId;
|
|
966
|
-
const sent = latestRef.current.drafts[id]
|
|
973
|
+
const sent = latestRef.current.drafts[id] ??
|
|
974
|
+
(latestOptionsRef.current.pendingIds?.has(id)
|
|
975
|
+
? latestRef.current.loadedValues[id]
|
|
976
|
+
: undefined);
|
|
967
977
|
if (sent === undefined)
|
|
968
978
|
return undefined;
|
|
969
979
|
const onBranch = () => latestOptionsRef.current.branchName === branch;
|
|
@@ -1137,7 +1147,9 @@ export function useDraftManager(options) {
|
|
|
1137
1147
|
// Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
|
|
1138
1148
|
const { drafts: remaining } = latestRef.current;
|
|
1139
1149
|
const settled = settledDuringRunRef.current;
|
|
1140
|
-
|
|
1150
|
+
const pendingNow = latestOptionsRef.current.pendingIds;
|
|
1151
|
+
result.failed = result.failed.filter((failure) => (failure.contentId in remaining || !!pendingNow?.has(failure.contentId)) &&
|
|
1152
|
+
!settled.has(failure.contentId));
|
|
1141
1153
|
result.total = result.saved.length + result.failed.length;
|
|
1142
1154
|
if (result.saved.length > 0)
|
|
1143
1155
|
latestOptionsRef.current.onSaved?.();
|
|
@@ -1351,6 +1363,8 @@ export function useDraftManager(options) {
|
|
|
1351
1363
|
if (!entry)
|
|
1352
1364
|
return false;
|
|
1353
1365
|
const id = entry.contentId;
|
|
1366
|
+
if (pendingIds?.has(id))
|
|
1367
|
+
return true;
|
|
1354
1368
|
if (!drafts[id])
|
|
1355
1369
|
return false;
|
|
1356
1370
|
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
@@ -1359,6 +1373,8 @@ export function useDraftManager(options) {
|
|
|
1359
1373
|
const isSelectedDirty = () => {
|
|
1360
1374
|
if (!currentId)
|
|
1361
1375
|
return false;
|
|
1376
|
+
if (pendingIds?.has(currentId))
|
|
1377
|
+
return true;
|
|
1362
1378
|
if (!drafts[currentId])
|
|
1363
1379
|
return false;
|
|
1364
1380
|
return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ResolvedFieldConflict } from '../../merge-conflicts.js';
|
|
1
2
|
import type { ApiErrorCode } from '../../api/types.js';
|
|
2
3
|
import type { EditorEntry, EditorCollection } from '../Editor.js';
|
|
3
4
|
import type { LogicalPath } from '../../paths/types.js';
|
|
@@ -62,6 +63,14 @@ export interface UseEntryManagerOptions {
|
|
|
62
63
|
* refreshes the entries, so what reads the same branch state reloads here.
|
|
63
64
|
*/
|
|
64
65
|
onContentRefresh?: (branch: string) => void;
|
|
66
|
+
/**
|
|
67
|
+
* The conflicts a save of an entry resolves (useConflictChoices.ts): `pending` names them as the
|
|
68
|
+
* save sends them, and `onSaved` hears which the save sent and which the server cleared.
|
|
69
|
+
*/
|
|
70
|
+
conflictResolutions?: {
|
|
71
|
+
pending: (branch: string, contentId: string) => ResolvedFieldConflict[];
|
|
72
|
+
onSaved: (branch: string, contentId: string, sent: ResolvedFieldConflict[], cleared: ResolvedFieldConflict[]) => void;
|
|
73
|
+
};
|
|
65
74
|
}
|
|
66
75
|
export interface UseEntryManagerReturn {
|
|
67
76
|
selectedPath: string;
|
|
@@ -339,15 +339,20 @@ export function useEntryManager(options) {
|
|
|
339
339
|
if (expectedVersion === undefined) {
|
|
340
340
|
throw new SaveApiError(409, "This entry hasn't finished loading, so it can't be saved yet. Reload it and try again (your unsaved edits will be lost).");
|
|
341
341
|
}
|
|
342
|
+
const resolving = options.conflictResolutions?.pending(requestBranch, entry.contentId) ?? [];
|
|
342
343
|
const writeBody = {
|
|
343
344
|
...payload,
|
|
344
345
|
expectedVersion,
|
|
346
|
+
...(resolving.length > 0 && { resolvedConflicts: resolving }),
|
|
345
347
|
};
|
|
346
348
|
const result = await apiClient.content.write(writeParams, writeBody);
|
|
347
349
|
if (isGatewayFailure(result))
|
|
348
350
|
throw new UnansweredError();
|
|
349
351
|
if (!result.ok)
|
|
350
352
|
throw new SaveApiError(result.status, result.error, result.fieldErrors, result.code);
|
|
353
|
+
if (resolving.length > 0) {
|
|
354
|
+
options.conflictResolutions?.onSaved(requestBranch, entry.contentId, resolving, result.data?.resolvedConflicts ?? []);
|
|
355
|
+
}
|
|
351
356
|
// Update stored version token from write response
|
|
352
357
|
if (typeof result.data?.version === 'number') {
|
|
353
358
|
entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An admin's release of a branch held as its record of conflicts was set aside
|
|
3
|
+
* (api/merge-conflicts.ts `releaseHold`). An unanswered request is settled by reading the record
|
|
4
|
+
* again, as the release may have landed.
|
|
5
|
+
*/
|
|
6
|
+
import type { ApiClient } from './context/index.js';
|
|
7
|
+
import type { ReleaseOutcome } from './components/ReleaseHoldButton.js';
|
|
8
|
+
/** A release's outcome, and on release whether it found the branch held, when that is known. */
|
|
9
|
+
export type ReleaseResult = ReleaseOutcome & {
|
|
10
|
+
wasHeld?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare function releaseConflictsHold(apiClient: Pick<ApiClient, 'conflicts'>, branch: string): Promise<ReleaseResult>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An admin's release of a branch held as its record of conflicts was set aside
|
|
3
|
+
* (api/merge-conflicts.ts `releaseHold`). An unanswered request is settled by reading the record
|
|
4
|
+
* again, as the release may have landed.
|
|
5
|
+
*/
|
|
6
|
+
import { isGatewayFailure, isUnanswered } from './unanswered.js';
|
|
7
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
8
|
+
const UNKNOWN = {
|
|
9
|
+
error: "The release may have gone through even though the server didn't answer. Reload the page to see whether the branch is still on hold.",
|
|
10
|
+
tone: 'warning',
|
|
11
|
+
};
|
|
12
|
+
const STILL_HELD = {
|
|
13
|
+
error: "The server didn't answer, and the branch is still on hold. Try again in a moment.",
|
|
14
|
+
tone: 'warning',
|
|
15
|
+
};
|
|
16
|
+
export async function releaseConflictsHold(apiClient, branch) {
|
|
17
|
+
const settle = async () => {
|
|
18
|
+
const now = await apiClient.conflicts.list({ branch }).catch(() => undefined);
|
|
19
|
+
if (!now?.ok || !now.data)
|
|
20
|
+
return UNKNOWN;
|
|
21
|
+
// Not held now, but this read can't say whose release that was.
|
|
22
|
+
return now.data.held ? STILL_HELD : { released: true };
|
|
23
|
+
};
|
|
24
|
+
let result;
|
|
25
|
+
try {
|
|
26
|
+
result = await apiClient.conflicts.releaseHold({ branch });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return isUnanswered(err) ? settle() : { error: getErrorMessage(err), tone: 'error' };
|
|
30
|
+
}
|
|
31
|
+
if (result.ok)
|
|
32
|
+
return { released: true, wasHeld: result.data?.released !== false };
|
|
33
|
+
if (isGatewayFailure(result))
|
|
34
|
+
return settle();
|
|
35
|
+
return {
|
|
36
|
+
error: result.error ?? "Couldn't release the hold. Try again in a moment.",
|
|
37
|
+
tone: result.status === 409 ? 'warning' : 'error',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Record a time the server reported, as of when its answer arrived. */
|
|
2
|
+
export declare function noteServerTime(serverTime: number, receivedAt?: number): void;
|
|
3
|
+
/** Now, by the server's clock as last heard; the browser's until then. */
|
|
4
|
+
export declare function serverNow(): number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's clock as the editor last heard it, so a wait timed against a server timestamp
|
|
3
|
+
* (base-update-wait.ts) doesn't depend on the browser's clock being right. One offset per page:
|
|
4
|
+
* the editor talks to one server.
|
|
5
|
+
*/
|
|
6
|
+
let offsetMs = 0;
|
|
7
|
+
/** Record a time the server reported, as of when its answer arrived. */
|
|
8
|
+
export function noteServerTime(serverTime, receivedAt = Date.now()) {
|
|
9
|
+
if (Number.isFinite(serverTime))
|
|
10
|
+
offsetMs = serverTime - receivedAt;
|
|
11
|
+
}
|
|
12
|
+
/** Now, by the server's clock as last heard; the browser's until then. */
|
|
13
|
+
export function serverNow() {
|
|
14
|
+
return Date.now() + offsetMs;
|
|
15
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Undo change" in the "Show changes" drawer: one saved change of an entry set back, in its form
|
|
3
|
+
* value, to how `source` has it: the base's version to undo it, the branch's saved one to redo it.
|
|
4
|
+
* Pure; the editor puts the result in the entry's draft, so an undo is an unsaved edit until saved.
|
|
5
|
+
* List and block items are found by item key, so an undo lands on the item the change named
|
|
6
|
+
* wherever it now sits, and an item put back keeps its key, which its comment threads are anchored
|
|
7
|
+
* by (`resolveThreadPath` in item-keys.ts).
|
|
8
|
+
*/
|
|
9
|
+
import type { EntryChangeSide } from '../api/branch-entry-changes.js';
|
|
10
|
+
import type { CanopyPathSegment } from './canopy-path.js';
|
|
11
|
+
import type { EntryChange } from './entry-diff.js';
|
|
12
|
+
type FormValue = Record<string, unknown>;
|
|
13
|
+
/** A side as the editor's form holds it: an md/mdx body under `body` (`normalizeContentPayload`). */
|
|
14
|
+
export declare const formValueOfSide: (side: EntryChangeSide) => FormValue;
|
|
15
|
+
/** A form value as a side shaped like `like`, for comparing it with the base. */
|
|
16
|
+
export declare const sideOfFormValue: (value: FormValue, like: EntryChangeSide) => EntryChangeSide;
|
|
17
|
+
/**
|
|
18
|
+
* `value` with `next` at a change's place, records on the way made as needed; undefined when the
|
|
19
|
+
* place is inside an item `value` no longer has.
|
|
20
|
+
*/
|
|
21
|
+
export declare const withValueAt: (value: FormValue, change: Pick<EntryChange, "dataPath">, next: unknown, bodyField?: string) => FormValue | undefined;
|
|
22
|
+
/** What `value` holds at a change's place; undefined where it has nothing there. */
|
|
23
|
+
export declare const valueAtChange: (value: FormValue, change: Pick<EntryChange, "dataPath">, bodyField?: string) => unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
|
|
26
|
+
* nothing: a moved item placed as `side` places it, anything else the same value at the change's
|
|
27
|
+
* path, an added or removed item's whole content included.
|
|
28
|
+
*/
|
|
29
|
+
export declare function holdsAs(value: FormValue, side: FormValue, change: EntryChange, bodyField?: string): boolean;
|
|
30
|
+
/** Whether a change has a place in the form value that an undo can set back. */
|
|
31
|
+
export declare const canSetBack: (change: EntryChange) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* `current` with `change` set to how `source` has it. A whole item added or removed is put in or
|
|
34
|
+
* taken out; a moved item goes back beside its neighbours in `source`; anything else takes
|
|
35
|
+
* `source`'s value at the change's path. Undefined when the change can't be placed in `current`,
|
|
36
|
+
* such as a field inside an item `current` no longer has. `bodyField` is the schema's body field
|
|
37
|
+
* on an md/mdx entry.
|
|
38
|
+
*/
|
|
39
|
+
export declare function setChangeBack(current: FormValue, source: FormValue, change: EntryChange, bodyField?: string): FormValue | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The canopy path (`blocks[2].title`) of a change in `value`, which comment threads anchor to;
|
|
42
|
+
* undefined when an item on it isn't in `value`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function canopyPathOf(value: FormValue, change: EntryChange, bodyField?: string): CanopyPathSegment[] | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* `current` with every one of `changes` set to how `source` has it, leaving anything else, such
|
|
47
|
+
* as an unsaved edit to an unchanged field, as it is. Items are placed in `source`'s order, so
|
|
48
|
+
* each lands beside a neighbour already back in place and the list ends in `source`'s order. A
|
|
49
|
+
* change that can't be placed is skipped.
|
|
50
|
+
*/
|
|
51
|
+
export declare function setAllBack(current: FormValue, source: FormValue, changes: readonly EntryChange[], bodyField?: string): FormValue;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Undo change" in the "Show changes" drawer: one saved change of an entry set back, in its form
|
|
3
|
+
* value, to how `source` has it: the base's version to undo it, the branch's saved one to redo it.
|
|
4
|
+
* Pure; the editor puts the result in the entry's draft, so an undo is an unsaved edit until saved.
|
|
5
|
+
* List and block items are found by item key, so an undo lands on the item the change named
|
|
6
|
+
* wherever it now sits, and an item put back keeps its key, which its comment threads are anchored
|
|
7
|
+
* by (`resolveThreadPath` in item-keys.ts).
|
|
8
|
+
*/
|
|
9
|
+
import equal from 'fast-deep-equal';
|
|
10
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
11
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
12
|
+
/** The form key of an md/mdx body, which the diff names by the schema's body field. */
|
|
13
|
+
const FORM_BODY_KEY = 'body';
|
|
14
|
+
/** A side as the editor's form holds it: an md/mdx body under `body` (`normalizeContentPayload`). */
|
|
15
|
+
export const formValueOfSide = (side) => side.body === undefined ? { ...side.data } : { ...side.data, [FORM_BODY_KEY]: side.body };
|
|
16
|
+
/** A form value as a side shaped like `like`, for comparing it with the base. */
|
|
17
|
+
export const sideOfFormValue = (value, like) => {
|
|
18
|
+
if (like.body === undefined)
|
|
19
|
+
return { ...like, data: value };
|
|
20
|
+
const { [FORM_BODY_KEY]: body, ...data } = value;
|
|
21
|
+
return { ...like, data, body: typeof body === 'string' ? body : '' };
|
|
22
|
+
};
|
|
23
|
+
const indexOfKey = (list, key) => list.findIndex((item) => isPlainRecord(item) && item[ITEM_KEY] === key);
|
|
24
|
+
/** A change's data path in form keys: the body field, when it heads the path, is `body`. */
|
|
25
|
+
const formPath = (change, bodyField) => {
|
|
26
|
+
const path = change.dataPath ?? [];
|
|
27
|
+
const head = path[0];
|
|
28
|
+
return bodyField && head && 'field' in head && head.field === bodyField
|
|
29
|
+
? [{ field: FORM_BODY_KEY }, ...path.slice(1)]
|
|
30
|
+
: path;
|
|
31
|
+
};
|
|
32
|
+
const step = (value, segment) => {
|
|
33
|
+
if ('field' in segment)
|
|
34
|
+
return isPlainRecord(value) ? value[segment.field] : undefined;
|
|
35
|
+
if ('item' in segment) {
|
|
36
|
+
if (!Array.isArray(value))
|
|
37
|
+
return undefined;
|
|
38
|
+
const index = indexOfKey(value, segment.item);
|
|
39
|
+
return index < 0 ? undefined : value[index];
|
|
40
|
+
}
|
|
41
|
+
return isPlainRecord(value) ? (isPlainRecord(value.value) ? value.value : value) : undefined;
|
|
42
|
+
};
|
|
43
|
+
const valueAt = (value, path) => path.reduce(step, value);
|
|
44
|
+
/**
|
|
45
|
+
* `value` with `next` at a change's place, records on the way made as needed; undefined when the
|
|
46
|
+
* place is inside an item `value` no longer has.
|
|
47
|
+
*/
|
|
48
|
+
export const withValueAt = (value, change, next, bodyField) => {
|
|
49
|
+
const placed = updateAt(value, formPath(change, bodyField), () => next);
|
|
50
|
+
return placed && isPlainRecord(placed.value) ? placed.value : undefined;
|
|
51
|
+
};
|
|
52
|
+
/** What `value` holds at a change's place; undefined where it has nothing there. */
|
|
53
|
+
export const valueAtChange = (value, change, bodyField) => valueAt(value, formPath(change, bodyField));
|
|
54
|
+
/** What a `replace` returns when the change has nothing to act on, so it can't be placed. */
|
|
55
|
+
const NOT_PLACED = Symbol('not-placed');
|
|
56
|
+
/**
|
|
57
|
+
* `value` with what is at `path` replaced by `replace`'s result, copied on the way down; a
|
|
58
|
+
* record missing on the way is made, so a field comes back inside an object the branch cleared.
|
|
59
|
+
* Undefined when the path leads through an item or block the value no longer has. A record
|
|
60
|
+
* member replaced by undefined is dropped.
|
|
61
|
+
*/
|
|
62
|
+
const updateAt = (value, path, replace) => {
|
|
63
|
+
if (path.length === 0) {
|
|
64
|
+
const next = replace(value);
|
|
65
|
+
return next === NOT_PLACED ? undefined : { value: next };
|
|
66
|
+
}
|
|
67
|
+
const [segment, ...rest] = path;
|
|
68
|
+
if ('field' in segment) {
|
|
69
|
+
const record = isPlainRecord(value) ? value : value == null ? {} : undefined;
|
|
70
|
+
if (!record)
|
|
71
|
+
return undefined;
|
|
72
|
+
const next = updateAt(record[segment.field], rest, replace);
|
|
73
|
+
if (!next)
|
|
74
|
+
return undefined;
|
|
75
|
+
const out = { ...record };
|
|
76
|
+
if (next.value === undefined)
|
|
77
|
+
delete out[segment.field];
|
|
78
|
+
else
|
|
79
|
+
out[segment.field] = next.value;
|
|
80
|
+
return { value: out };
|
|
81
|
+
}
|
|
82
|
+
if ('item' in segment) {
|
|
83
|
+
if (!Array.isArray(value))
|
|
84
|
+
return undefined;
|
|
85
|
+
const index = indexOfKey(value, segment.item);
|
|
86
|
+
if (index < 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const next = updateAt(value[index], rest, replace);
|
|
89
|
+
if (!next)
|
|
90
|
+
return undefined;
|
|
91
|
+
const out = [...value];
|
|
92
|
+
out[index] = next.value;
|
|
93
|
+
return { value: out };
|
|
94
|
+
}
|
|
95
|
+
if (!isPlainRecord(value))
|
|
96
|
+
return undefined;
|
|
97
|
+
if (!isPlainRecord(value.value))
|
|
98
|
+
return updateAt(value, rest, replace);
|
|
99
|
+
const next = updateAt(value.value, rest, replace);
|
|
100
|
+
return next && { value: { ...value, value: next.value } };
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* `item` placed in `list` (which lacks it) where `order` has it: after the nearest item before it
|
|
104
|
+
* in `order` that `list` holds, else before the nearest one after it, else at its place in `order`.
|
|
105
|
+
*/
|
|
106
|
+
const placeLike = (list, item, key, order) => {
|
|
107
|
+
const at = indexOfKey(order, key);
|
|
108
|
+
const indexIn = (other) => isPlainRecord(other) ? indexOfKey(list, other[ITEM_KEY]) : -1;
|
|
109
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
110
|
+
const index = indexIn(order[i]);
|
|
111
|
+
if (index >= 0)
|
|
112
|
+
return [...list.slice(0, index + 1), item, ...list.slice(index + 1)];
|
|
113
|
+
}
|
|
114
|
+
for (let i = at + 1; at > 0 && i < order.length; i++) {
|
|
115
|
+
const index = indexIn(order[i]);
|
|
116
|
+
if (index >= 0)
|
|
117
|
+
return [...list.slice(0, index), item, ...list.slice(index)];
|
|
118
|
+
}
|
|
119
|
+
return at > 0 ? [...list.slice(0, at), item, ...list.slice(at)] : [item, ...list];
|
|
120
|
+
};
|
|
121
|
+
const isNothing = (value) => value === undefined || isEmptyContainer(value);
|
|
122
|
+
/**
|
|
123
|
+
* Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
|
|
124
|
+
* nothing: a moved item placed as `side` places it, anything else the same value at the change's
|
|
125
|
+
* path, an added or removed item's whole content included.
|
|
126
|
+
*/
|
|
127
|
+
export function holdsAs(value, side, change, bodyField) {
|
|
128
|
+
if (change.item && change.kind === 'moved') {
|
|
129
|
+
const placed = setChangeBack(value, side, change, bodyField);
|
|
130
|
+
return !!placed && equal(placed, value);
|
|
131
|
+
}
|
|
132
|
+
const path = formPath(change, bodyField);
|
|
133
|
+
const [mine, theirs] = [valueAt(value, path), valueAt(side, path)];
|
|
134
|
+
return (isNothing(mine) && isNothing(theirs)) || equal(mine, theirs);
|
|
135
|
+
}
|
|
136
|
+
/** Whether a change has a place in the form value that an undo can set back. */
|
|
137
|
+
export const canSetBack = (change) => change.dataPath !== undefined;
|
|
138
|
+
/**
|
|
139
|
+
* `current` with `change` set to how `source` has it. A whole item added or removed is put in or
|
|
140
|
+
* taken out; a moved item goes back beside its neighbours in `source`; anything else takes
|
|
141
|
+
* `source`'s value at the change's path. Undefined when the change can't be placed in `current`,
|
|
142
|
+
* such as a field inside an item `current` no longer has. `bodyField` is the schema's body field
|
|
143
|
+
* on an md/mdx entry.
|
|
144
|
+
*/
|
|
145
|
+
export function setChangeBack(current, source, change, bodyField) {
|
|
146
|
+
if (!canSetBack(change))
|
|
147
|
+
return undefined;
|
|
148
|
+
const path = formPath(change, bodyField);
|
|
149
|
+
const last = path[path.length - 1];
|
|
150
|
+
let next;
|
|
151
|
+
if (change.item && last && 'item' in last) {
|
|
152
|
+
const listPath = path.slice(0, -1);
|
|
153
|
+
const order = valueAt(source, listPath);
|
|
154
|
+
const sourceList = Array.isArray(order) ? order : [];
|
|
155
|
+
const wanted = valueAt(source, path);
|
|
156
|
+
next = updateAt(current, listPath, (old) => {
|
|
157
|
+
const list = Array.isArray(old) ? old : [];
|
|
158
|
+
const index = indexOfKey(list, last.item);
|
|
159
|
+
if (wanted === undefined)
|
|
160
|
+
return index < 0 ? old : list.filter((_, i) => i !== index);
|
|
161
|
+
if (change.kind === 'moved') {
|
|
162
|
+
if (index < 0)
|
|
163
|
+
return NOT_PLACED;
|
|
164
|
+
const without = list.filter((_, i) => i !== index);
|
|
165
|
+
return placeLike(without, list[index], last.item, sourceList);
|
|
166
|
+
}
|
|
167
|
+
return index >= 0 ? old : placeLike(list, structuredClone(wanted), last.item, sourceList);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const wanted = valueAt(source, path);
|
|
172
|
+
next = updateAt(current, path, () => wanted === undefined ? undefined : structuredClone(wanted));
|
|
173
|
+
}
|
|
174
|
+
const placed = next && isPlainRecord(next.value) ? next.value : undefined;
|
|
175
|
+
return placed && withoutEmptyContainers(placed, source, path);
|
|
176
|
+
}
|
|
177
|
+
const isEmptyContainer = (value) => Array.isArray(value)
|
|
178
|
+
? value.length === 0
|
|
179
|
+
: isPlainRecord(value) && Object.keys(value).length === 0;
|
|
180
|
+
/**
|
|
181
|
+
* `value` without the records and lists on `path` that setting a change back left empty where
|
|
182
|
+
* `source` has none, so a value set back matches `source` rather than holding `seo: {}`.
|
|
183
|
+
*/
|
|
184
|
+
const withoutEmptyContainers = (value, source, path) => {
|
|
185
|
+
let out = value;
|
|
186
|
+
for (let end = path.length; end > 0; end--) {
|
|
187
|
+
const at = path.slice(0, end);
|
|
188
|
+
if (!('field' in at[at.length - 1]))
|
|
189
|
+
continue;
|
|
190
|
+
if (valueAt(source, at) !== undefined || !isEmptyContainer(valueAt(out, at)))
|
|
191
|
+
continue;
|
|
192
|
+
const next = updateAt(out, at, () => undefined);
|
|
193
|
+
if (next && isPlainRecord(next.value))
|
|
194
|
+
out = next.value;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* The canopy path (`blocks[2].title`) of a change in `value`, which comment threads anchor to;
|
|
200
|
+
* undefined when an item on it isn't in `value`.
|
|
201
|
+
*/
|
|
202
|
+
export function canopyPathOf(value, change, bodyField) {
|
|
203
|
+
const out = [];
|
|
204
|
+
let at = value;
|
|
205
|
+
for (const segment of formPath(change, bodyField)) {
|
|
206
|
+
if ('item' in segment) {
|
|
207
|
+
const index = Array.isArray(at) ? indexOfKey(at, segment.item) : -1;
|
|
208
|
+
if (index < 0)
|
|
209
|
+
return undefined;
|
|
210
|
+
out.push(index);
|
|
211
|
+
}
|
|
212
|
+
else if ('field' in segment) {
|
|
213
|
+
out.push(segment.field);
|
|
214
|
+
}
|
|
215
|
+
at = step(at, segment);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/** An item change's place in `source`'s list: -1 for an item `source` lacks; undefined otherwise. */
|
|
220
|
+
const sourcePosition = (source, change, bodyField) => {
|
|
221
|
+
if (!change.item)
|
|
222
|
+
return undefined;
|
|
223
|
+
const path = formPath(change, bodyField);
|
|
224
|
+
const last = path[path.length - 1];
|
|
225
|
+
if (!last || !('item' in last))
|
|
226
|
+
return undefined;
|
|
227
|
+
const list = valueAt(source, path.slice(0, -1));
|
|
228
|
+
return Array.isArray(list) ? indexOfKey(list, last.item) : -1;
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* `current` with every one of `changes` set to how `source` has it, leaving anything else, such
|
|
232
|
+
* as an unsaved edit to an unchanged field, as it is. Items are placed in `source`'s order, so
|
|
233
|
+
* each lands beside a neighbour already back in place and the list ends in `source`'s order. A
|
|
234
|
+
* change that can't be placed is skipped.
|
|
235
|
+
*/
|
|
236
|
+
export function setAllBack(current, source, changes, bodyField) {
|
|
237
|
+
const ordered = changes
|
|
238
|
+
.map((change) => ({ change, position: sourcePosition(source, change, bodyField) }))
|
|
239
|
+
.sort((a, b) => (a.position ?? -2) - (b.position ?? -2));
|
|
240
|
+
return ordered.reduce((value, { change }) => setChangeBack(value, source, change, bodyField) ?? value, current);
|
|
241
|
+
}
|
package/dist/git-manager.d.ts
CHANGED
|
@@ -560,6 +560,11 @@ export declare class GitManager {
|
|
|
560
560
|
* path. Any other failure, such as an unknown `rev`, throws.
|
|
561
561
|
*/
|
|
562
562
|
showFile(rev: string, filePath: string): Promise<string | undefined>;
|
|
563
|
+
/**
|
|
564
|
+
* The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
|
|
565
|
+
* clone has no such blob. Any other failure throws.
|
|
566
|
+
*/
|
|
567
|
+
readBlob(sha: string, maxBytes: number): Promise<string | 'too-large' | undefined>;
|
|
563
568
|
ensureAuthor(author: {
|
|
564
569
|
name: string;
|
|
565
570
|
email: string;
|
package/dist/git-manager.js
CHANGED
|
@@ -1763,6 +1763,36 @@ export class GitManager {
|
|
|
1763
1763
|
throw err;
|
|
1764
1764
|
}
|
|
1765
1765
|
}
|
|
1766
|
+
/**
|
|
1767
|
+
* The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
|
|
1768
|
+
* clone has no such blob. Any other failure throws.
|
|
1769
|
+
*/
|
|
1770
|
+
async readBlob(sha, maxBytes) {
|
|
1771
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha))
|
|
1772
|
+
return undefined;
|
|
1773
|
+
let sizeText;
|
|
1774
|
+
try {
|
|
1775
|
+
sizeText = (await this.git.raw(['cat-file', '-s', '--end-of-options', sha])).trim();
|
|
1776
|
+
}
|
|
1777
|
+
catch (err) {
|
|
1778
|
+
// LC_ALL=C (gitChildEnv), so git's own wording is stable to match.
|
|
1779
|
+
if (/could not get object info|Not a valid object name/.test(getErrorMessage(err))) {
|
|
1780
|
+
return undefined;
|
|
1781
|
+
}
|
|
1782
|
+
throw err;
|
|
1783
|
+
}
|
|
1784
|
+
// simple-git resolves "" when git exits non-zero without writing to stderr, so anything but
|
|
1785
|
+
// digits is a failed read rather than an empty blob.
|
|
1786
|
+
if (!/^\d+$/.test(sizeText))
|
|
1787
|
+
throw new Error(`git cat-file -s ${sha} printed no size`);
|
|
1788
|
+
const size = Number(sizeText);
|
|
1789
|
+
if (size > maxBytes)
|
|
1790
|
+
return 'too-large';
|
|
1791
|
+
const text = await this.git.raw(['cat-file', 'blob', '--end-of-options', sha]);
|
|
1792
|
+
if (size > 0 && text === '')
|
|
1793
|
+
throw new Error(`git cat-file blob ${sha} printed nothing`);
|
|
1794
|
+
return text;
|
|
1795
|
+
}
|
|
1766
1796
|
async ensureAuthor(author) {
|
|
1767
1797
|
const config = (await this.git.listConfig());
|
|
1768
1798
|
// Verify this is a CanopyCMS-managed workspace before setting author
|
package/dist/item-keys.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export declare const ITEM_KEY = "_key";
|
|
|
12
12
|
export declare const isItemKey: (value: unknown) => value is string;
|
|
13
13
|
/** A new random item key: 12 Base58 characters. */
|
|
14
14
|
export declare function mintItemKey(): string;
|
|
15
|
+
/**
|
|
16
|
+
* True for a key with the shape {@link contentKey} gives, `h` and a 53-bit hash in at most 10
|
|
17
|
+
* Base58 digits (`-2` and on for a repeat): one a read wrote down. A key {@link mintItemKey}
|
|
18
|
+
* minted is 12 digits long, so it never has this shape.
|
|
19
|
+
*/
|
|
20
|
+
export declare const hasContentKeyShape: (key: string) => boolean;
|
|
21
|
+
/** True for a key a read or {@link mintItemKey} wrote, never one a person typed, such as `faq`. */
|
|
22
|
+
export declare const hasMachineKeyShape: (key: string) => boolean;
|
|
15
23
|
/**
|
|
16
24
|
* How `withItemKeys` keys an item that lacks a usable key, or repeats one an earlier item of its
|
|
17
25
|
* list holds:
|
|
@@ -31,6 +39,11 @@ type ListField = ObjectFieldConfig | BlockFieldConfig;
|
|
|
31
39
|
export declare function withItemKeys(fields: readonly FieldConfig[], data: Record<string, unknown>, fill: ItemKeyFill): Record<string, unknown>;
|
|
32
40
|
/** `item`, a copy about to join `field`'s list, with fresh keys for it and every item inside it. */
|
|
33
41
|
export declare function withFreshItemKeys<T>(field: ListField, item: T): T;
|
|
42
|
+
/**
|
|
43
|
+
* The key a read gives each item of a list, as `withItemKeys` with `content` does: its own usable
|
|
44
|
+
* key, else a hash of its content. Undefined for an item that isn't a record.
|
|
45
|
+
*/
|
|
46
|
+
export declare const itemKeysAsRead: (items: readonly unknown[]) => Array<string | undefined>;
|
|
34
47
|
/**
|
|
35
48
|
* The keys of the list items on `canopyPath` in `value`, outermost first: what a new field thread
|
|
36
49
|
* stores to name them. Undefined when an item on the path has no usable key or the path isn't
|
package/dist/item-keys.js
CHANGED
|
@@ -40,6 +40,14 @@ function contentKey(item) {
|
|
|
40
40
|
} while (n > 0);
|
|
41
41
|
return `h${out}`;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* True for a key with the shape {@link contentKey} gives, `h` and a 53-bit hash in at most 10
|
|
45
|
+
* Base58 digits (`-2` and on for a repeat): one a read wrote down. A key {@link mintItemKey}
|
|
46
|
+
* minted is 12 digits long, so it never has this shape.
|
|
47
|
+
*/
|
|
48
|
+
export const hasContentKeyShape = (key) => /^h[1-9A-HJ-NP-Za-km-z]{1,10}(-\d+)?$/.test(key);
|
|
49
|
+
/** True for a key a read or {@link mintItemKey} wrote, never one a person typed, such as `faq`. */
|
|
50
|
+
export const hasMachineKeyShape = (key) => hasContentKeyShape(key) || /^[1-9A-HJ-NP-Za-km-z]{12}$/.test(key);
|
|
43
51
|
const isListField = (field) => field.type === 'block' || (field.type === 'object' && !!field.list);
|
|
44
52
|
/**
|
|
45
53
|
* `data` with every object-list item and block keyed, each unique in its list, nested lists
|
|
@@ -72,6 +80,22 @@ export function withFreshItemKeys(field, item) {
|
|
|
72
80
|
return keyList(field, [item], 'fresh')[0];
|
|
73
81
|
}
|
|
74
82
|
function keyList(field, items, fill) {
|
|
83
|
+
const keys = listKeys(items, fill);
|
|
84
|
+
let changed = false;
|
|
85
|
+
const out = items.map((item, i) => {
|
|
86
|
+
if (!isPlainRecord(item))
|
|
87
|
+
return item;
|
|
88
|
+
let next = keyInside(field, item, fill);
|
|
89
|
+
if (keys[i] !== item[ITEM_KEY])
|
|
90
|
+
next = { ...next, [ITEM_KEY]: keys[i] };
|
|
91
|
+
if (next !== item)
|
|
92
|
+
changed = true;
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
return changed ? out : items;
|
|
96
|
+
}
|
|
97
|
+
/** The key each record of `items` ends with under `fill`; undefined for an item that isn't one. */
|
|
98
|
+
function listKeys(items, fill) {
|
|
75
99
|
const held = new Set();
|
|
76
100
|
if (fill !== 'fresh') {
|
|
77
101
|
for (const item of items) {
|
|
@@ -80,27 +104,25 @@ function keyList(field, items, fill) {
|
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
const kept = new Set();
|
|
83
|
-
|
|
84
|
-
const out = items.map((item) => {
|
|
107
|
+
return items.map((item) => {
|
|
85
108
|
if (!isPlainRecord(item))
|
|
86
|
-
return
|
|
87
|
-
let next = keyInside(field, item, fill);
|
|
109
|
+
return undefined;
|
|
88
110
|
const own = item[ITEM_KEY];
|
|
89
111
|
if (fill !== 'fresh' && isItemKey(own) && !kept.has(own)) {
|
|
90
112
|
kept.add(own);
|
|
113
|
+
return own;
|
|
91
114
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
next = { ...next, [ITEM_KEY]: key };
|
|
97
|
-
}
|
|
98
|
-
if (next !== item)
|
|
99
|
-
changed = true;
|
|
100
|
-
return next;
|
|
115
|
+
const key = fill === 'content' ? unheld(contentKey(item), held) : mintItemKey();
|
|
116
|
+
held.add(key);
|
|
117
|
+
kept.add(key);
|
|
118
|
+
return key;
|
|
101
119
|
});
|
|
102
|
-
return changed ? out : items;
|
|
103
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The key a read gives each item of a list, as `withItemKeys` with `content` does: its own usable
|
|
123
|
+
* key, else a hash of its content. Undefined for an item that isn't a record.
|
|
124
|
+
*/
|
|
125
|
+
export const itemKeysAsRead = (items) => listKeys(items, 'content');
|
|
104
126
|
/** `key`, or with a suffix when another item of the list already holds it (identical items). */
|
|
105
127
|
function unheld(key, held) {
|
|
106
128
|
let out = key;
|