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.
Files changed (158) hide show
  1. package/CHANGELOG.md +174 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-changes.d.ts +1 -0
  4. package/dist/api/branch-changes.js +1 -1
  5. package/dist/api/branch-entry-changes.d.ts +92 -7
  6. package/dist/api/branch-entry-changes.js +272 -38
  7. package/dist/api/branch-review.js +45 -13
  8. package/dist/api/branch-status.d.ts +12 -0
  9. package/dist/api/branch-status.js +13 -0
  10. package/dist/api/branch-update-from-base.d.ts +23 -0
  11. package/dist/api/branch-update-from-base.js +176 -0
  12. package/dist/api/branch.d.ts +5 -0
  13. package/dist/api/branch.js +1 -0
  14. package/dist/api/client.d.ts +17 -2
  15. package/dist/api/client.js +26 -0
  16. package/dist/api/comments.d.ts +2 -2
  17. package/dist/api/content.d.ts +38 -1
  18. package/dist/api/content.js +41 -5
  19. package/dist/api/merge-conflicts.d.ts +226 -0
  20. package/dist/api/merge-conflicts.js +674 -0
  21. package/dist/api/routes.js +2 -0
  22. package/dist/api/types.d.ts +7 -3
  23. package/dist/base-update.d.ts +26 -0
  24. package/dist/base-update.js +31 -0
  25. package/dist/branch-metadata-file.d.ts +51 -0
  26. package/dist/branch-metadata-file.js +11 -0
  27. package/dist/branch-schema-cache.d.ts +12 -0
  28. package/dist/branch-schema-cache.js +27 -2
  29. package/dist/cli/backfill-item-keys.d.ts +77 -0
  30. package/dist/cli/backfill-item-keys.js +320 -0
  31. package/dist/cli/cli.d.ts +1 -1
  32. package/dist/cli/cli.js +9713 -1493
  33. package/dist/cli/generate-ai-content.js +415 -244
  34. package/dist/cli/init.js +6 -0
  35. package/dist/cli/site-config.d.ts +14 -2
  36. package/dist/cli/site-config.js +20 -4
  37. package/dist/comment-store.d.ts +8 -0
  38. package/dist/comment-store.js +28 -0
  39. package/dist/content-index-generation.d.ts +1 -4
  40. package/dist/content-index-generation.js +1 -4
  41. package/dist/content-store.d.ts +65 -3
  42. package/dist/content-store.js +121 -15
  43. package/dist/editor/BranchManager.d.ts +4 -1
  44. package/dist/editor/BranchManager.js +9 -5
  45. package/dist/editor/Editor.js +549 -53
  46. package/dist/editor/EntryNavigator.d.ts +10 -2
  47. package/dist/editor/EntryNavigator.js +12 -4
  48. package/dist/editor/FormRenderer.d.ts +8 -2
  49. package/dist/editor/FormRenderer.js +5 -6
  50. package/dist/editor/base-update-wait.d.ts +74 -0
  51. package/dist/editor/base-update-wait.js +91 -0
  52. package/dist/editor/branch-status.js +1 -1
  53. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  54. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  55. package/dist/editor/components/BranchPill.d.ts +4 -1
  56. package/dist/editor/components/BranchPill.js +5 -2
  57. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  58. package/dist/editor/components/ConflictsGroup.js +364 -0
  59. package/dist/editor/components/EditorHeader.d.ts +16 -1
  60. package/dist/editor/components/EditorHeader.js +92 -30
  61. package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
  62. package/dist/editor/components/EntryChangesDrawer.js +164 -162
  63. package/dist/editor/components/EntryHeader.d.ts +3 -1
  64. package/dist/editor/components/EntryHeader.js +4 -6
  65. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  66. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  67. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  68. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  69. package/dist/editor/components/RetryToastMessage.js +1 -1
  70. package/dist/editor/components/change-parts.d.ts +51 -0
  71. package/dist/editor/components/change-parts.js +171 -0
  72. package/dist/editor/conflict-cards.d.ts +90 -0
  73. package/dist/editor/conflict-cards.js +279 -0
  74. package/dist/editor/conflict-choices.d.ts +60 -0
  75. package/dist/editor/conflict-choices.js +108 -0
  76. package/dist/editor/conflict-notice.d.ts +25 -0
  77. package/dist/editor/conflict-notice.js +39 -0
  78. package/dist/editor/copy.d.ts +20 -0
  79. package/dist/editor/copy.js +20 -0
  80. package/dist/editor/entry-diff.d.ts +18 -2
  81. package/dist/editor/entry-diff.js +26 -32
  82. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  83. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  84. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  85. package/dist/editor/hooks/useBranchManager.js +28 -7
  86. package/dist/editor/hooks/useBranchesData.js +3 -0
  87. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  88. package/dist/editor/hooks/useConflictChoices.js +75 -0
  89. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  90. package/dist/editor/hooks/useConflictsData.js +89 -0
  91. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  92. package/dist/editor/hooks/useDraftManager.js +24 -8
  93. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  94. package/dist/editor/hooks/useEntryManager.js +5 -0
  95. package/dist/editor/release-hold.d.ts +12 -0
  96. package/dist/editor/release-hold.js +39 -0
  97. package/dist/editor/server-clock.d.ts +4 -0
  98. package/dist/editor/server-clock.js +15 -0
  99. package/dist/editor/undo-change.d.ts +52 -0
  100. package/dist/editor/undo-change.js +241 -0
  101. package/dist/git-manager.d.ts +5 -0
  102. package/dist/git-manager.js +30 -0
  103. package/dist/item-keys.d.ts +13 -0
  104. package/dist/item-keys.js +36 -14
  105. package/dist/merge/conflict-path.d.ts +40 -0
  106. package/dist/merge/conflict-path.js +128 -0
  107. package/dist/merge/diff3.d.ts +27 -0
  108. package/dist/merge/diff3.js +113 -0
  109. package/dist/merge/entry-merge.d.ts +94 -0
  110. package/dist/merge/entry-merge.js +875 -0
  111. package/dist/merge/list-alignment.d.ts +52 -0
  112. package/dist/merge/list-alignment.js +441 -0
  113. package/dist/merge/text-merge.d.ts +29 -0
  114. package/dist/merge/text-merge.js +59 -0
  115. package/dist/merge/value-equality.d.ts +18 -0
  116. package/dist/merge/value-equality.js +50 -0
  117. package/dist/merge-conflicts.d.ts +232 -0
  118. package/dist/merge-conflicts.js +451 -0
  119. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  120. package/dist/operating-mode/client-safe-strategy.js +6 -0
  121. package/dist/operating-mode/types.d.ts +5 -0
  122. package/dist/services.d.ts +16 -1
  123. package/dist/services.js +82 -22
  124. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  125. package/dist/types.d.ts +28 -2
  126. package/dist/utils/content-serialize.d.ts +18 -1
  127. package/dist/utils/content-serialize.js +12 -0
  128. package/dist/utils/content-write-lock.d.ts +3 -3
  129. package/dist/utils/content-write-lock.js +3 -3
  130. package/dist/utils/markdown-body-splice.d.ts +1 -1
  131. package/dist/utils/markdown-body-splice.js +1 -19
  132. package/dist/utils/markdown-parse.d.ts +9 -0
  133. package/dist/utils/markdown-parse.js +27 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/worker/branch-merge.d.ts +53 -37
  137. package/dist/worker/branch-merge.js +139 -54
  138. package/dist/worker/cms-worker.d.ts +16 -3
  139. package/dist/worker/cms-worker.js +59 -12
  140. package/dist/worker/field-merge.d.ts +65 -0
  141. package/dist/worker/field-merge.js +636 -0
  142. package/dist/worker/git-sync.d.ts +1 -1
  143. package/dist/worker/git-sync.js +7 -6
  144. package/dist/worker/github-commits.d.ts +42 -10
  145. package/dist/worker/github-commits.js +287 -47
  146. package/dist/worker/github-gateway-http.js +17 -9
  147. package/dist/worker/github-gateway-wire.d.ts +21 -12
  148. package/dist/worker/github-gateway-wire.js +5 -1
  149. package/dist/worker/github-gateway.d.ts +10 -1
  150. package/dist/worker/github-gateway.js +18 -6
  151. package/dist/worker/github-transfer.d.ts +5 -3
  152. package/dist/worker/github-transfer.js +8 -5
  153. package/dist/worker/rebase.d.ts +5 -4
  154. package/dist/worker/rebase.js +293 -156
  155. package/dist/worker/task-runner.d.ts +1 -1
  156. package/dist/worker/task-runner.js +14 -2
  157. package/dist/worker/worker-context.d.ts +18 -2
  158. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * What a choice on a conflict card does to the open entry's form value (conflict-cards.ts builds
3
+ * the cards). Pure. A choice is an unsaved edit: the save that writes it sends the conflict's key in
4
+ * `resolvedConflicts` (api/content.ts), which is what resolves it.
5
+ */
6
+ import { holdsAs, setChangeBack, valueAtChange, withValueAt } from './undo-change.js';
7
+ /** The change-shaped handle `undo-change.ts` places a value by. */
8
+ export const placeOf = (field) => ({
9
+ key: field.key,
10
+ dataPath: field.dataPath,
11
+ path: [],
12
+ kind: 'changed',
13
+ ...(field.item && { item: true }),
14
+ });
15
+ /** Whether a choice says everything its conflict needs. */
16
+ export const isDecided = (field, choice) => !!choice &&
17
+ (choice.how !== 'regions' ||
18
+ (!!field.text && field.text.regions.every((_, i) => choice.regions[i] !== undefined)));
19
+ const linesOf = (text, count) => count === 0 || (count === undefined && text === '') ? [] : text.split('\n');
20
+ /** A region's lines under `choice`; undecided, they are yours, as the merge left them. */
21
+ const regionLines = (region, choice) => {
22
+ const yours = linesOf(region.branch, region.lines);
23
+ // Counted, as the branch's are: joined, one blank line reads as none.
24
+ const latest = linesOf(region.base, region.baseLines);
25
+ if (choice === 'latest')
26
+ return latest;
27
+ if (choice === 'both')
28
+ return [...latest, ...yours];
29
+ return yours;
30
+ };
31
+ /**
32
+ * The text the merge left with each region as `regions` chooses it. A region's lines sit where the
33
+ * merge put yours (`TextConflictRegion.line`), so regions are placed from the last up.
34
+ * @internal Exported for tests.
35
+ */
36
+ export function textWithChoices(text, regions) {
37
+ const endBreak = text.merged.endsWith('\n');
38
+ const body = endBreak ? text.merged.slice(0, -1) : text.merged;
39
+ const lines = body === '' && !endBreak ? [] : body.split('\n');
40
+ for (let i = text.regions.length - 1; i >= 0; i--) {
41
+ const region = text.regions[i];
42
+ lines.splice(region.line, region.lines, ...regionLines(region, regions[i]));
43
+ }
44
+ const joined = lines.join('\n');
45
+ return endBreak ? `${joined}\n` : joined;
46
+ }
47
+ /** What the form would hold at the conflict's place under `choice`, given your saved value. */
48
+ const expectedUnder = (field, choice, saved) => {
49
+ if (!choice || choice.how === 'yours')
50
+ return { from: saved };
51
+ if (choice.how === 'latest')
52
+ return field.latest ? { from: field.latest } : undefined;
53
+ if (choice.how === 'regions' && field.text)
54
+ return { text: textWithChoices(field.text, choice.regions) };
55
+ return undefined;
56
+ };
57
+ /**
58
+ * Whether the form still holds the conflict's place as the last choice (or, before any, your saved
59
+ * value) left it. False once it was edited by hand: a choice would then replace that edit.
60
+ */
61
+ export function holdsChoice(current, saved, field, choice, bodyField) {
62
+ const expected = expectedUnder(field, choice, saved);
63
+ // A place with no path, such as where the entry is, holds nothing the form could edit.
64
+ if (!expected || field.dataPath.length === 0)
65
+ return true;
66
+ if ('text' in expected)
67
+ return valueAtChange(current, field, bodyField) === expected.text;
68
+ // An item on the way that the form no longer has holds nothing, though an empty value would
69
+ // compare equal to the nothing found there.
70
+ const from = expected.from;
71
+ const itemGone = field.dataPath.some((segment, i) => 'item' in segment &&
72
+ valueAtChange(from, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !== undefined &&
73
+ valueAtChange(current, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) === undefined);
74
+ if (itemGone)
75
+ return false;
76
+ // Long text checks the merged text, which your saved value holds until a region is chosen.
77
+ if (field.text && (!choice || choice.how === 'yours')) {
78
+ return valueAtChange(current, field, bodyField) === valueAtChange(saved, field, bodyField);
79
+ }
80
+ return holdsAs(current, expected.from, placeOf(field), bodyField);
81
+ }
82
+ /**
83
+ * `current` with the conflict's place as `source` has it: a list item `source` lacks taken out, or
84
+ * one `current` lacks put in where `source` has it, through `undo-change.ts`; anything else, an item
85
+ * both hold included, replaced where it is, by key. Undefined when it can't be placed.
86
+ */
87
+ const placeFrom = (current, source, field, bodyField) => {
88
+ const place = placeOf(field);
89
+ const wanted = valueAtChange(source, place, bodyField);
90
+ if (wanted === undefined ||
91
+ (field.item && valueAtChange(current, place, bodyField) === undefined))
92
+ return setChangeBack(current, source, place, bodyField);
93
+ return withValueAt(current, place, structuredClone(wanted), bodyField);
94
+ };
95
+ /**
96
+ * `current` with the latest version at the conflict's place; undefined when it can't be placed, as
97
+ * where the latest version couldn't be read or doesn't hold the items on the place's path
98
+ * ({@link FieldConflict.latestPlaceable}).
99
+ */
100
+ export const withLatest = (current, field, bodyField) => field.latest && field.latestPlaceable
101
+ ? placeFrom(current, field.latest, field, bodyField)
102
+ : undefined;
103
+ /** `current` with your saved value back at the conflict's place, as before any choice. */
104
+ export const withYours = (current, saved, field, bodyField) => placeFrom(current, saved, field, bodyField);
105
+ /** `current` with long text as `regions` chooses it. */
106
+ export const withRegions = (current, field, regions, bodyField) => field.text
107
+ ? withValueAt(current, field, textWithChoices(field.text, regions), bodyField)
108
+ : undefined;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What the editor says about a branch's open conflicts outside "Show changes": the open entry's
3
+ * notice, Submit's reason, the bar's badge and the "Get latest changes" toast. A count is of the
4
+ * choices left (`MergeConflictsData.openCount`): one per field, and one per entry-level conflict
5
+ * (`openConflictCount`, merge-conflicts.ts). No copy names a
6
+ * branch: an author may not know the branch theirs was cut from.
7
+ */
8
+ import type { MergeConflictListItem } from '../api/merge-conflicts.js';
9
+ /** The notice above an entry's form while it has conflicts; the list item when it is loaded. */
10
+ export declare function entryConflictNotice(item: MergeConflictListItem | undefined): string;
11
+ /** Why Submit for review waits while choices are open. */
12
+ export declare const conflictsSubmitReason: (count: number) => string;
13
+ /** The bar's "Needs action" detail while choices are open. */
14
+ export declare const conflictsHoldMessage: (count: number) => string;
15
+ /**
16
+ * The "Get latest changes" toast when the merge left choices; undefined: not yet counted. None
17
+ * left is a plain success, which base-update-wait.ts says instead.
18
+ */
19
+ export declare const latestChangesConflictsMessage: (count: number | undefined) => string;
20
+ /** The bar's "Needs action" detail while the branch's record of conflicts is held. */
21
+ export declare const conflictRecordHeldMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved until an admin releases the hold.";
22
+ /** The same, to an admin, who has the way out beside it. */
23
+ export declare const conflictRecordHeldAdminMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved. Use \"Release hold\" to let it go on.";
24
+ /** What "Release hold" gives up, in its confirm. */
25
+ export declare const releaseHoldConsequences = "Releasing lets this branch be submitted and approved again. The list of fields that needed a choice is lost for good: each field keeps the value it has now, and no one will be asked to choose. Check the branch's changes first.";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What the editor says about a branch's open conflicts outside "Show changes": the open entry's
3
+ * notice, Submit's reason, the bar's badge and the "Get latest changes" toast. A count is of the
4
+ * choices left (`MergeConflictsData.openCount`): one per field, and one per entry-level conflict
5
+ * (`openConflictCount`, merge-conflicts.ts). No copy names a
6
+ * branch: an author may not know the branch theirs was cut from.
7
+ */
8
+ /** "1 field needs", "2 fields need". */
9
+ const fieldsNeed = (count) => count === 1 ? '1 field needs' : `${count} fields need`;
10
+ const CHOOSE = 'Choose what to keep in "Show changes".';
11
+ /** The notice above an entry's form while it has conflicts; the list item when it is loaded. */
12
+ export function entryConflictNotice(item) {
13
+ switch (item?.kind) {
14
+ case 'deleted-on-base':
15
+ return `This entry was deleted in the latest version, but you changed it. ${CHOOSE}`;
16
+ case 'unreadable':
17
+ case 'deleted-on-branch':
18
+ return `This entry was changed by you and by someone else. ${CHOOSE}`;
19
+ case 'fields':
20
+ return `${item.fieldCount === 1 ? '1 field was' : `${item.fieldCount} fields were`} changed by you and by someone else. ${CHOOSE}`;
21
+ default:
22
+ return `Some fields were changed by you and by someone else. ${CHOOSE}`;
23
+ }
24
+ }
25
+ /** Why Submit for review waits while choices are open. */
26
+ export const conflictsSubmitReason = (count) => `${fieldsNeed(count)} your choice before this branch can be submitted. Choose what to keep in "Show changes" on each flagged entry.`;
27
+ /** The bar's "Needs action" detail while choices are open. */
28
+ export const conflictsHoldMessage = (count) => `${fieldsNeed(count)} your choice: changed by you and by someone else. Choose what to keep in "Show changes" on each flagged entry, then submit.`;
29
+ /**
30
+ * The "Get latest changes" toast when the merge left choices; undefined: not yet counted. None
31
+ * left is a plain success, which base-update-wait.ts says instead.
32
+ */
33
+ export const latestChangesConflictsMessage = (count) => `Latest changes added. ${count === undefined ? 'Some fields need' : fieldsNeed(count)} your choice.`;
34
+ /** The bar's "Needs action" detail while the branch's record of conflicts is held. */
35
+ export const conflictRecordHeldMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved until an admin releases the hold.";
36
+ /** The same, to an admin, who has the way out beside it. */
37
+ export const conflictRecordHeldAdminMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved. Use \"Release hold\" to let it go on.";
38
+ /** What "Release hold" gives up, in its confirm. */
39
+ export const releaseHoldConsequences = "Releasing lets this branch be submitted and approved again. The list of fields that needed a choice is lost for good: each field keeps the value it has now, and no one will be asked to choose. Check the branch's changes first.";
@@ -9,6 +9,7 @@ export declare const EDITOR_ACTIONS: {
9
9
  readonly refreshFromServer: "Refresh from server";
10
10
  readonly submitForReview: "Submit for review";
11
11
  readonly withdrawFromReview: "Withdraw from review";
12
+ readonly getLatestChanges: "Get latest changes";
12
13
  readonly approve: "Approve";
13
14
  readonly review: "Review";
14
15
  readonly requestChanges: "Request changes";
@@ -22,7 +23,24 @@ export declare const EDITOR_ACTIONS: {
22
23
  readonly changeUrl: "Change URL";
23
24
  readonly changeSlug: "Change slug";
24
25
  readonly showChanges: "Show changes";
26
+ readonly undoChange: "Undo change";
27
+ readonly redoChange: "Redo change";
28
+ readonly undoAllChanges: "Undo all changes";
29
+ readonly redoAllChanges: "Redo all changes";
30
+ readonly restoreEntry: "Restore entry";
31
+ readonly deleteEntry: "Delete entry";
32
+ readonly useLatestVersion: "Use latest version";
33
+ readonly keepYourVersion: "Keep your version";
34
+ readonly useBoth: "Use both";
35
+ readonly markResolved: "Mark resolved";
36
+ readonly releaseHold: "Release hold";
37
+ readonly removeIt: "Remove it";
38
+ readonly keepIt: "Keep it";
39
+ readonly keepItDeleted: "Keep it deleted";
40
+ readonly chooseAgain: "Choose again";
25
41
  };
42
+ /** Withdraw's confirm on a branch that conflicts with its base, which then gets the latest changes. */
43
+ export declare const WITHDRAW_AND_GET_LATEST = "Withdraw and get latest changes";
26
44
  /**
27
45
  * Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
28
46
  * `one` and `many` name what the list holds.
@@ -35,6 +53,8 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
35
53
  * says how to finish the submit, where it can be retried at all.
36
54
  */
37
55
  export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
56
+ /** Why an entry's form, and every control that would change the entry, can't act for this user. */
57
+ export declare const NO_EDIT_ACCESS_REASON = "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it.";
38
58
  /** Why only an admin may delete a branch that may hold commits nothing else has. */
39
59
  export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
40
60
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
@@ -9,6 +9,7 @@ export const EDITOR_ACTIONS = {
9
9
  refreshFromServer: 'Refresh from server',
10
10
  submitForReview: 'Submit for review',
11
11
  withdrawFromReview: 'Withdraw from review',
12
+ getLatestChanges: 'Get latest changes',
12
13
  approve: 'Approve',
13
14
  review: 'Review',
14
15
  requestChanges: 'Request changes',
@@ -22,7 +23,24 @@ export const EDITOR_ACTIONS = {
22
23
  changeUrl: 'Change URL',
23
24
  changeSlug: 'Change slug',
24
25
  showChanges: 'Show changes',
26
+ undoChange: 'Undo change',
27
+ redoChange: 'Redo change',
28
+ undoAllChanges: 'Undo all changes',
29
+ redoAllChanges: 'Redo all changes',
30
+ restoreEntry: 'Restore entry',
31
+ deleteEntry: 'Delete entry',
32
+ useLatestVersion: 'Use latest version',
33
+ keepYourVersion: 'Keep your version',
34
+ useBoth: 'Use both',
35
+ markResolved: 'Mark resolved',
36
+ releaseHold: 'Release hold',
37
+ removeIt: 'Remove it',
38
+ keepIt: 'Keep it',
39
+ keepItDeleted: 'Keep it deleted',
40
+ chooseAgain: 'Choose again',
25
41
  };
42
+ /** Withdraw's confirm on a branch that conflicts with its base, which then gets the latest changes. */
43
+ export const WITHDRAW_AND_GET_LATEST = 'Withdraw and get latest changes';
26
44
  /**
27
45
  * Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
28
46
  * `one` and `many` name what the list holds.
@@ -37,6 +55,8 @@ export const rewrittenSubmitReason = (isAdmin) => isAdmin
37
55
  * says how to finish the submit, where it can be retried at all.
38
56
  */
39
57
  export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
58
+ /** Why an entry's form, and every control that would change the entry, can't act for this user. */
59
+ export const NO_EDIT_ACCESS_REASON = "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it.";
40
60
  /** Why only an admin may delete a branch that may hold commits nothing else has. */
41
61
  export const DELETE_NEEDS_ADMIN_REASON = 'This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.';
42
62
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
@@ -99,9 +99,22 @@ export type DisplayValue = {
99
99
  kind: 'raw';
100
100
  text: string;
101
101
  };
102
+ /**
103
+ * One step into a form value: a record's member, a list item by its item key, or a block item's
104
+ * data, which is its `value` record or, in the inline `_type` shape, the item itself.
105
+ */
106
+ export type DataPathSegment = {
107
+ field: string;
108
+ } | {
109
+ item: string;
110
+ } | {
111
+ blockData: true;
112
+ };
102
113
  export interface EntryChange {
103
114
  /** Stable among one diff's changes: its data path, list items named by key. */
104
115
  key: string;
116
+ /** Where the change is in the form value; absent for the entry type, which no field holds. */
117
+ dataPath?: DataPathSegment[];
105
118
  /** Human labels from the entry down to the change: "Page sections › Hero › Heading". */
106
119
  path: string[];
107
120
  kind: 'added' | 'removed' | 'changed' | 'moved';
@@ -127,6 +140,8 @@ export interface EntryDiffInput {
127
140
  after: EntryChangeSide | null;
128
141
  /** A referenced entry's title, from the entries the editor has listed. */
129
142
  referenceTitle?: (id: string) => string | undefined;
143
+ /** False when only which changes there are matters: no text is diffed for its marks. */
144
+ marks?: boolean;
130
145
  }
131
146
  /**
132
147
  * The difference between two texts. Short single-line text is diffed by word; anything else by
@@ -136,8 +151,9 @@ export interface EntryDiffInput {
136
151
  * text's spacing, so one side read back from its parts would not be the text it was.
137
152
  * Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
138
153
  * diff runs until the later `wordDeadline`.
139
- * @internal Exported for tests.
140
154
  */
141
155
  export declare function textDiff(before: string, after: string, long?: boolean, deadline?: number, wordDeadline?: number): TextDiff | undefined;
156
+ /** `value` of `field` as the drawer shows it whole, for a value a conflict compares whole. */
157
+ export declare function displayWhole(field: FieldConfig | undefined, value: unknown, referenceTitle?: (id: string) => string | undefined): DisplayValue;
142
158
  /** The entry's changes between `before` and `after`, in schema order. */
143
- export declare function computeEntryDiff({ fields, before, after, referenceTitle, }: EntryDiffInput): EntryDiff;
159
+ export declare function computeEntryDiff({ fields, before, after, referenceTitle, marks, }: EntryDiffInput): EntryDiff;
@@ -5,8 +5,8 @@
5
5
  * so a reordered item reads as moved, not as one removed and one added.
6
6
  */
7
7
  import { diffLines, diffWordsWithSpace } from 'diff';
8
- import equal from 'fast-deep-equal';
9
8
  import { ITEM_KEY } from '../item-keys.js';
9
+ import { isEmptyValue as isEmpty, normalizedValue as normalized, sameValue, } from '../merge/value-equality.js';
10
10
  import { findBodyFieldName } from '../utils/body-field.js';
11
11
  import { isCalendarDay, localMidnight } from '../utils/calendar-day.js';
12
12
  import { flattenGroupFields } from '../utils/flatten-group-fields.js';
@@ -40,33 +40,18 @@ const formValue = (fields, side) => {
40
40
  return side.data;
41
41
  return { ...side.data, [side.bodyFieldName ?? findBodyFieldName(fields)]: side.body };
42
42
  };
43
- const isEmpty = (value) => value === undefined ||
44
- value === null ||
45
- value === '' ||
46
- (Array.isArray(value) && value.length === 0) ||
47
- (isPlainRecord(value) && Object.keys(value).every((k) => k === ITEM_KEY || isEmpty(value[k])));
48
- /** `value` without item keys and empty members, so equal content compares equal. */
49
- const normalized = (value) => {
50
- if (Array.isArray(value))
51
- return value.map(normalized);
52
- if (!isPlainRecord(value))
53
- return value;
54
- const out = {};
55
- for (const [k, v] of Object.entries(value)) {
56
- if (k !== ITEM_KEY && !isEmpty(v))
57
- out[k] = normalized(v);
58
- }
59
- return out;
60
- };
61
- const sameValue = (a, b) => (isEmpty(a) && isEmpty(b)) || equal(normalized(a), normalized(b));
62
- const at = (parent, label, key) => ({
43
+ const at = (parent, label, key, segment) => ({
63
44
  path: [...parent.path, label],
64
45
  key: parent.key ? `${parent.key}.${key}` : key,
46
+ ...(parent.segments && segment && { segments: [...parent.segments, segment] }),
65
47
  });
48
+ /** `where`'s data path as a change carries it. */
49
+ const dataPathOf = (where) => where.segments ? { dataPath: where.segments } : {};
66
50
  /** A member the schema doesn't name, under an "Other fields" crumb. */
67
51
  const otherAt = (parent, name) => ({
68
52
  path: [...parent.path, OTHER_FIELDS_LABEL, humanizeName(name)],
69
53
  key: `${parent.key}?${name}`,
54
+ ...(parent.segments && { segments: [...parent.segments, { field: name }] }),
70
55
  });
71
56
  /** The file name of an image `src`, its query and fragment dropped. */
72
57
  const fileName = (src) => {
@@ -245,7 +230,7 @@ class Walker {
245
230
  const flat = flattenGroupFields(fields);
246
231
  const known = new Set(flat.map((f) => f.name));
247
232
  for (const field of flat) {
248
- this.field(field, before?.[field.name], after?.[field.name], at(where, schemaLabel(field), field.name));
233
+ this.field(field, before?.[field.name], after?.[field.name], at(where, schemaLabel(field), field.name, { field: field.name }));
249
234
  }
250
235
  const others = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
251
236
  for (const name of others) {
@@ -287,7 +272,7 @@ class Walker {
287
272
  if (sameValue(before, after) || sameInstant(field, before, after))
288
273
  return;
289
274
  const kind = isEmpty(before) ? 'added' : isEmpty(after) ? 'removed' : 'changed';
290
- const change = { key: where.key, path: where.path, kind };
275
+ const change = { key: where.key, ...dataPathOf(where), path: where.path, kind };
291
276
  if (kind !== 'added')
292
277
  change.before = this.display(field, before);
293
278
  if (kind !== 'removed')
@@ -337,13 +322,15 @@ class Walker {
337
322
  const a = isPlainRecord(after) ? resolveBlockItem(field, after) : undefined;
338
323
  const sameTemplate = isPlainRecord(before) && isPlainRecord(after) && before.template === after.template;
339
324
  if (b && a && sameTemplate) {
340
- this.fields(a.fields, b.data, a.data, where);
325
+ const data = where.segments && { segments: [...where.segments, { blockData: true }] };
326
+ this.fields(a.fields, b.data, a.data, { ...where, ...data });
341
327
  return;
342
328
  }
343
329
  if (sameValue(before, after))
344
330
  return;
345
331
  this.changes.push({
346
332
  key: where.key,
333
+ ...dataPathOf(where),
347
334
  path: where.path,
348
335
  kind: 'changed',
349
336
  before: this.blockRecord(field, before),
@@ -373,10 +360,11 @@ class Walker {
373
360
  const from = bIndex.get(key);
374
361
  // A matched item is named as it was at the base, so a change to its title doesn't rename
375
362
  // the path to it.
376
- const itemAt = at(where, from === undefined ? item.heading(value, index) : item.heading(bItems[from], from), key);
363
+ const itemAt = at(where, from === undefined ? item.heading(value, index) : item.heading(bItems[from], from), key, { item: key });
377
364
  if (from === undefined) {
378
365
  this.changes.push({
379
366
  key: itemAt.key,
367
+ ...dataPathOf(itemAt),
380
368
  path: itemAt.path,
381
369
  kind: 'added',
382
370
  item: true,
@@ -387,6 +375,7 @@ class Walker {
387
375
  if (!stayed.has(key)) {
388
376
  this.changes.push({
389
377
  key: `${itemAt.key}#moved`,
378
+ ...dataPathOf(itemAt),
390
379
  path: itemAt.path,
391
380
  kind: 'moved',
392
381
  item: true,
@@ -400,9 +389,10 @@ class Walker {
400
389
  const key = bKeys[index];
401
390
  if (aSet.has(key))
402
391
  return;
403
- const itemAt = at(where, item.heading(value, index), key);
392
+ const itemAt = at(where, item.heading(value, index), key, { item: key });
404
393
  this.changes.push({
405
394
  key: itemAt.key,
395
+ ...dataPathOf(itemAt),
406
396
  path: itemAt.path,
407
397
  kind: 'removed',
408
398
  item: true,
@@ -496,7 +486,6 @@ const splitLines = (value) => {
496
486
  * text's spacing, so one side read back from its parts would not be the text it was.
497
487
  * Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
498
488
  * diff runs until the later `wordDeadline`.
499
- * @internal Exported for tests.
500
489
  */
501
490
  export function textDiff(before, after, long = false, deadline = Date.now() + DIFF_TIMEOUT_MS, wordDeadline = Date.now() + SHORT_DIFF_TIMEOUT_MS) {
502
491
  // jsdiff restarts its clock on each call, so each is given only what is left of the budget.
@@ -584,12 +573,17 @@ const withContext = (lines) => {
584
573
  }
585
574
  return out;
586
575
  };
576
+ /** `value` of `field` as the drawer shows it whole, for a value a conflict compares whole. */
577
+ export function displayWhole(field, value, referenceTitle) {
578
+ return new Walker(referenceTitle, 0, 0).display(field, value);
579
+ }
587
580
  /** The entry's changes between `before` and `after`, in schema order. */
588
- export function computeEntryDiff({ fields, before, after, referenceTitle, }) {
589
- // One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS.
590
- const started = Date.now();
591
- const walker = new Walker(referenceTitle, started + DIFF_TIMEOUT_MS, started + SHORT_DIFF_TIMEOUT_MS);
592
- const root = { path: [], key: '' };
581
+ export function computeEntryDiff({ fields, before, after, referenceTitle, marks = true, }) {
582
+ // One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS. Without
583
+ // marks, both deadlines have passed before the walk starts.
584
+ const started = marks ? Date.now() : 0;
585
+ const walker = new Walker(referenceTitle, started + (marks ? DIFF_TIMEOUT_MS : 0), started + (marks ? SHORT_DIFF_TIMEOUT_MS : 0));
586
+ const root = { path: [], key: '', segments: [] };
593
587
  if (before && after && before.entryType !== after.entryType) {
594
588
  walker.value(undefined, humanizeName(before.entryType), humanizeName(after.entryType), at(root, 'Entry type', '$entryType'));
595
589
  }
@@ -0,0 +1,36 @@
1
+ import type { BranchListItem } from '../../api/branch.js';
2
+ import type { OperatingMode } from '../../operating-mode/types.js';
3
+ import { type BaseUpdateWait } from '../base-update-wait.js';
4
+ export interface UseBaseUpdateOptions {
5
+ /** The open branch. */
6
+ branch: BranchListItem | undefined;
7
+ operatingMode: OperatingMode;
8
+ /** The user may take the branch's workflow actions, which asking for the latest changes is. */
9
+ permitted: boolean;
10
+ /** Entries with unsaved changes on the branch; asking waits until they are saved. */
11
+ unsavedCount: number;
12
+ /** Re-read the branch's entries and the open one, keeping drafts: the worker rewrote them. */
13
+ onUpdated: () => void;
14
+ /** Opens the branch's conflicts, offered by the toast of a merge that left some. */
15
+ onShowConflicts?: () => void;
16
+ }
17
+ export interface UseBaseUpdateReturn {
18
+ /** "Get latest changes" applies here: a worker answers, the branch is editing, the user may ask. */
19
+ available: boolean;
20
+ /** The open branch's wait; `waiting` makes its form read-only. */
21
+ wait: BaseUpdateWait;
22
+ /** The open branch's request itself is in flight. */
23
+ requesting: boolean;
24
+ /** Why it can't be asked now; undefined when it can. */
25
+ blockedReason?: string;
26
+ /** Asks for the open branch, unless unsaved changes hold it. */
27
+ request: () => Promise<void>;
28
+ }
29
+ /**
30
+ * "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
31
+ * waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
32
+ * reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
33
+ * clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
34
+ * it lands after it.
35
+ */
36
+ export declare function useBaseUpdate(options: UseBaseUpdateOptions): UseBaseUpdateReturn;