@agent-native/core-corpus 0.212.1 → 0.214.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.
Files changed (90) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/onboarding-journeys/SKILL.md +29 -14
  3. package/corpus/templates/analytics/CHANGELOG.md +11 -0
  4. package/corpus/templates/analytics/actions/estimate-onboarding-journey-cost.ts +23 -6
  5. package/corpus/templates/analytics/actions/get-onboarding-journey.ts +20 -5
  6. package/corpus/templates/analytics/actions/provider-api-register.ts +2 -7
  7. package/corpus/templates/analytics/changelog/2026-10-21-analytics-recognizes-questions-about-connected-data-product-.md +6 -0
  8. package/corpus/templates/analytics/changelog/2026-10-21-onboarding-journeys-now-honor-larger-event-row-budgets.md +6 -0
  9. package/corpus/templates/analytics/changelog/2026-10-21-preserve-unknown-replay-availability-in-partial-journeys.md +6 -0
  10. package/corpus/templates/analytics/package.json +6 -0
  11. package/corpus/templates/analytics/scripts/journey-capture-plan.ts +18 -7
  12. package/corpus/templates/analytics/scripts/journey-capture.ts +3 -1
  13. package/corpus/templates/analytics/server/lib/db-admin-connections.ts +19 -12
  14. package/corpus/templates/analytics/server/lib/journey-tree.ts +24 -9
  15. package/corpus/templates/analytics/server/lib/onboarding-journey-cost.ts +163 -94
  16. package/corpus/templates/analytics/server/lib/onboarding-journey.ts +162 -74
  17. package/corpus/templates/assets/server/lib/org-admin.ts +20 -16
  18. package/corpus/templates/brain/server/lib/zoom-governance.ts +11 -6
  19. package/corpus/templates/clips/CHANGELOG.md +1 -0
  20. package/corpus/templates/clips/changelog/2026-10-16-rewind-status-says-when-it-remembers-audio.md +6 -0
  21. package/corpus/templates/clips/desktop/src/app.tsx +7 -4
  22. package/corpus/templates/clips/desktop/src/components/RewindCaptureStatus.tsx +25 -0
  23. package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +6 -1
  24. package/corpus/templates/clips/desktop/src/shared/config.ts +3 -0
  25. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2 -0
  26. package/corpus/templates/clips/server/lib/recordings.ts +4 -19
  27. package/corpus/templates/community/qr/package.json +1 -1
  28. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +15 -1
  29. package/corpus/templates/content/CHANGELOG.md +13 -0
  30. package/corpus/templates/content/actions/_content-space-access.ts +95 -6
  31. package/corpus/templates/content/actions/_database-utils.ts +20 -16
  32. package/corpus/templates/content/actions/_database-view-setup.ts +305 -11
  33. package/corpus/templates/content/actions/_github-source-freshness.ts +72 -17
  34. package/corpus/templates/content/actions/_github-source-metadata.ts +63 -17
  35. package/corpus/templates/content/actions/_github-source.ts +141 -77
  36. package/corpus/templates/content/actions/_property-utils.ts +3 -0
  37. package/corpus/templates/content/actions/check-github-connection.ts +15 -0
  38. package/corpus/templates/content/actions/create-github-collection.ts +180 -0
  39. package/corpus/templates/content/actions/disconnect-content-database-source.ts +5 -0
  40. package/corpus/templates/content/actions/link-github-folder.ts +104 -36
  41. package/corpus/templates/content/actions/refresh-content-database-source.ts +37 -4
  42. package/corpus/templates/content/actions/sync-github-folder.ts +23 -5
  43. package/corpus/templates/content/actions/update-content-database-view.ts +18 -2
  44. package/corpus/templates/content/actions/view-screen.ts +12 -0
  45. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +263 -35
  46. package/corpus/templates/content/app/components/editor/database/GitHubLinkDialog.tsx +938 -0
  47. package/corpus/templates/content/app/components/editor/database/GitHubSourcePanel.tsx +228 -0
  48. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +561 -0
  49. package/corpus/templates/content/app/components/editor/database/GitHubUnlinkDialog.tsx +96 -0
  50. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +410 -0
  51. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +426 -0
  52. package/corpus/templates/content/app/components/editor/database/ListView.tsx +136 -18
  53. package/corpus/templates/content/app/components/editor/database/github-availability.ts +147 -0
  54. package/corpus/templates/content/app/components/editor/database/github-source-text.ts +280 -0
  55. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +3 -0
  56. package/corpus/templates/content/app/components/editor/database/settings.tsx +3 -3
  57. package/corpus/templates/content/app/components/editor/database/view-config.ts +4 -0
  58. package/corpus/templates/content/app/components/layout/Layout.tsx +2 -0
  59. package/corpus/templates/content/app/global.css +13 -0
  60. package/corpus/templates/content/app/hooks/content-action-refresh.ts +6 -0
  61. package/corpus/templates/content/app/hooks/use-content-database.ts +3 -1
  62. package/corpus/templates/content/app/hooks/use-github-folder.ts +199 -0
  63. package/corpus/templates/content/app/i18n-data.ts +186 -1
  64. package/corpus/templates/content/app/lib/github-link-dialog.ts +86 -0
  65. package/corpus/templates/content/changelog/2026-10-18-connected-ai-assistants-can-link-a-new-collection-to-a-github.md +1 -1
  66. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +6 -0
  67. package/corpus/templates/content/changelog/2026-10-19-list-rows-can-be-customized.md +6 -0
  68. package/corpus/templates/content/changelog/2026-10-19-list-views-fit-phone-screens.md +6 -0
  69. package/corpus/templates/content/changelog/2026-10-21-organization-favorites-include-documents-shared-with-the-cur.md +6 -0
  70. package/corpus/templates/content/docs/product/capabilities/content.source.sync-policy.md +32 -7
  71. package/corpus/templates/content/docs/product/encyclopedia.md +3 -3
  72. package/corpus/templates/content/docs/product/features/content.feature.trust-your-connected-sources.md +2 -2
  73. package/corpus/templates/content/docs/product/roadmap.md +1 -1
  74. package/corpus/templates/content/parity/matrix.md +42 -42
  75. package/corpus/templates/content/parity/matrix.ts +14 -0
  76. package/corpus/templates/content/server/lib/github-source-client.ts +50 -1
  77. package/corpus/templates/content/shared/api.ts +21 -0
  78. package/corpus/templates/content/shared/database-item-layout.ts +591 -0
  79. package/corpus/templates/content/shared/github-source.ts +68 -0
  80. package/corpus/templates/design/CHANGELOG.md +1 -0
  81. package/corpus/templates/design/actions/connect-assets-mcp.ts +5 -9
  82. package/corpus/templates/design/changelog/2026-10-21-journey-canvases-accept-partial-analytics-samples-when-repla.md +6 -0
  83. package/corpus/templates/design/shared/journey-canvas.ts +6 -4
  84. package/corpus/templates/dispatch/.agents/skills/a2a-protocol/SKILL.md +16 -13
  85. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  86. package/corpus/templates/dispatch/changelog/2026-10-21-customer-ai-credit-questions-route-to-account-health-instead.md +6 -0
  87. package/corpus/templates/factory/server/lib/require-workspace-member.ts +5 -17
  88. package/corpus/templates/mail/server/lib/queued-drafts.ts +9 -4
  89. package/corpus/templates/slides/server/workspace-defaults.ts +12 -15
  90. package/package.json +1 -1
@@ -1,3 +1,8 @@
1
+ import {
2
+ ActionContractError,
3
+ isAgentConnectionRequiredError,
4
+ } from "@agent-native/core/action";
5
+
1
6
  import type { GitHubSourceProblemCode } from "../../shared/github-source.js";
2
7
  import { executeGitHubSourceRequest } from "./provider-api.js";
3
8
 
@@ -201,7 +206,19 @@ async function githubGet(
201
206
  if (target.signal && (target.signal.aborted || endedBySignal(error))) {
202
207
  throw new GitHubSourceDeadlineError();
203
208
  }
204
- throw error;
209
+ // A missing connection is the caller's to explain. Anything else the
210
+ // provider client throws, like a dropped connection, means GitHub never
211
+ // answered.
212
+ if (
213
+ isAgentConnectionRequiredError(error) ||
214
+ error instanceof ActionContractError
215
+ ) {
216
+ throw error;
217
+ }
218
+ throw Object.assign(
219
+ new GitHubSourceError("unavailable", "GitHub couldn't be reached."),
220
+ { cause: error },
221
+ );
205
222
  }
206
223
  const result = asRecord(raw);
207
224
  const response = asRecord(result.response) as unknown as ProviderResponse;
@@ -301,6 +318,38 @@ async function refineNotFound(
301
318
  throw new GitHubSourceError(inside, message, 404);
302
319
  }
303
320
 
321
+ /** How long the connection check waits for GitHub before giving up. */
322
+ const CONNECTION_CHECK_MS = 5_000;
323
+
324
+ /**
325
+ * Whether the person has a GitHub connection Content can read through. It
326
+ * asks for the rate limit, which reads no repository and costs none of it.
327
+ * Null means GitHub couldn't be asked, so the caller shouldn't say either way.
328
+ */
329
+ export async function readGitHubConnected(
330
+ connectionId?: string | null,
331
+ ): Promise<boolean | null> {
332
+ try {
333
+ const raw = await executeGitHubSourceRequest({
334
+ provider: "github",
335
+ method: "GET",
336
+ path: "/rate_limit",
337
+ connectionId: connectionId ?? undefined,
338
+ signal: AbortSignal.timeout(CONNECTION_CHECK_MS),
339
+ });
340
+ const status = asRecord(asRecord(raw).response).status;
341
+ if (typeof status !== "number") return null;
342
+ return status !== 401;
343
+ } catch (error) {
344
+ if (isAgentConnectionRequiredError(error)) return false;
345
+ console.warn(
346
+ "[github-source] connection check failed:",
347
+ error instanceof Error ? error.message : String(error),
348
+ );
349
+ return null;
350
+ }
351
+ }
352
+
304
353
  /** The branch a repository URL without `/tree/` means. */
305
354
  export async function readGitHubDefaultBranch(
306
355
  target: GitHubRepositoryTarget,
@@ -453,6 +453,22 @@ export interface ContentDatabaseFormQuestion {
453
453
  required: boolean;
454
454
  }
455
455
 
456
+ export type ContentDatabaseItemLeadingKind =
457
+ | "none"
458
+ | "page_icon"
459
+ | "checkbox"
460
+ | "status";
461
+ export type ContentDatabaseItemTitleWeight = "regular" | "medium" | "bold";
462
+
463
+ /** Which property fills each part of a list row. */
464
+ export interface ContentDatabaseItemLayout {
465
+ leading: { kind: ContentDatabaseItemLeadingKind; propertyId: string | null };
466
+ titleWeight: ContentDatabaseItemTitleWeight;
467
+ line2: { propertyId: string | null; lines: 1 | 2 | 3 };
468
+ line3: string[];
469
+ trailing: string[];
470
+ }
471
+
456
472
  export interface ContentDatabaseView {
457
473
  id: string;
458
474
  name: string;
@@ -477,6 +493,8 @@ export interface ContentDatabaseView {
477
493
  rowDensity?: ContentDatabaseRowDensity;
478
494
  openPagesIn?: ContentDatabaseOpenPagesIn;
479
495
  formQuestions?: ContentDatabaseFormQuestion[];
496
+ /** List rows only. Null or absent keeps the default rows. */
497
+ itemLayout?: ContentDatabaseItemLayout | null;
480
498
  }
481
499
 
482
500
  export interface ContentDatabaseViewConfig {
@@ -1005,7 +1023,10 @@ export interface ContentGitHubSourceStatus {
1005
1023
  pendingCommitSha: string | null;
1006
1024
  progress: { done: number; total: number } | null;
1007
1025
  files: number;
1026
+ /** Up to 50 of the skipped files, with why each was skipped. */
1008
1027
  skipped: Array<{ path: string; reason: GitHubSourceSkipReason }>;
1028
+ /** How many files were skipped in all. */
1029
+ skippedCount: number;
1009
1030
  /** Images not shown yet because file storage isn't set up. */
1010
1031
  imagesWaiting: number;
1011
1032
  problem: {
@@ -0,0 +1,591 @@
1
+ import type {
2
+ ContentDatabaseItemLayout,
3
+ ContentDatabaseItemLeadingKind,
4
+ ContentDatabaseItemTitleWeight,
5
+ } from "./api";
6
+ import {
7
+ isPrimaryBlocksField,
8
+ type DocumentPropertyOptions,
9
+ type DocumentPropertyType,
10
+ type DocumentPropertyVisibility,
11
+ } from "./properties";
12
+
13
+ export const CONTENT_DATABASE_ITEM_LEADING_KINDS = [
14
+ "none",
15
+ "page_icon",
16
+ "checkbox",
17
+ "status",
18
+ ] as const satisfies readonly ContentDatabaseItemLeadingKind[];
19
+
20
+ export const CONTENT_DATABASE_ITEM_TITLE_WEIGHTS = [
21
+ "regular",
22
+ "medium",
23
+ "bold",
24
+ ] as const satisfies readonly ContentDatabaseItemTitleWeight[];
25
+
26
+ export const CONTENT_DATABASE_ITEM_LINE2_LINES = [1, 2, 3] as const;
27
+
28
+ /** Most properties line 3 or the trailing slot may show. */
29
+ export const MAX_ITEM_LAYOUT_META_PROPERTIES = 6;
30
+
31
+ export const CONTENT_DATABASE_ITEM_LAYOUT_SLOTS = [
32
+ "leading",
33
+ "title",
34
+ "line2",
35
+ "line3",
36
+ "trailing",
37
+ ] as const;
38
+
39
+ export type ContentDatabaseItemLayoutSlot =
40
+ (typeof CONTENT_DATABASE_ITEM_LAYOUT_SLOTS)[number];
41
+
42
+ export const CONTENT_DATABASE_ITEM_LAYOUT_PRESET_IDS = [
43
+ "default",
44
+ "checklist",
45
+ "reminders",
46
+ "tracker",
47
+ "digest",
48
+ ] as const;
49
+
50
+ export type ContentDatabaseItemLayoutPresetId =
51
+ (typeof CONTENT_DATABASE_ITEM_LAYOUT_PRESET_IDS)[number];
52
+
53
+ export interface ContentDatabaseItemLayoutPropertyLike {
54
+ definition: {
55
+ id: string;
56
+ type: DocumentPropertyType;
57
+ options?: DocumentPropertyOptions | null;
58
+ visibility?: DocumentPropertyVisibility;
59
+ };
60
+ }
61
+
62
+ /** Always-hidden properties stay out of rows, as they do in every view. */
63
+ function showableProperties<P extends ContentDatabaseItemLayoutPropertyLike>(
64
+ properties: readonly P[],
65
+ ): P[] {
66
+ return properties.filter(
67
+ (property) => property.definition.visibility !== "always_hide",
68
+ );
69
+ }
70
+
71
+ const LEADING_PROPERTY_TYPES: Partial<
72
+ Record<ContentDatabaseItemLeadingKind, DocumentPropertyType>
73
+ > = {
74
+ checkbox: "checkbox",
75
+ status: "status",
76
+ };
77
+
78
+ const LINE2_PROPERTY_TYPES: readonly DocumentPropertyType[] = [
79
+ "text",
80
+ "url",
81
+ "blocks",
82
+ ];
83
+
84
+ /** Properties a leading checkbox or status can show. */
85
+ export function itemLayoutLeadingCandidates<
86
+ P extends ContentDatabaseItemLayoutPropertyLike,
87
+ >(properties: readonly P[], kind: ContentDatabaseItemLeadingKind): P[] {
88
+ const type = LEADING_PROPERTY_TYPES[kind];
89
+ if (!type) return [];
90
+ return showableProperties(properties).filter(
91
+ (property) => property.definition.type === type,
92
+ );
93
+ }
94
+
95
+ /** Text-like properties line 2 can show. The page body arrives separately. */
96
+ export function itemLayoutLine2Candidates<
97
+ P extends ContentDatabaseItemLayoutPropertyLike,
98
+ >(properties: readonly P[]): P[] {
99
+ return showableProperties(properties).filter(
100
+ (property) =>
101
+ LINE2_PROPERTY_TYPES.includes(property.definition.type) &&
102
+ !(
103
+ property.definition.type === "blocks" &&
104
+ isPrimaryBlocksField(property.definition.options ?? {})
105
+ ),
106
+ );
107
+ }
108
+
109
+ /** Properties line 3 and the trailing slot can show as short values. */
110
+ export function itemLayoutMetaCandidates<
111
+ P extends ContentDatabaseItemLayoutPropertyLike,
112
+ >(properties: readonly P[]): P[] {
113
+ return showableProperties(properties).filter(
114
+ (property) => property.definition.type !== "blocks",
115
+ );
116
+ }
117
+
118
+ function propertyIdOrNull(value: unknown) {
119
+ if (typeof value !== "string") return null;
120
+ const id = value.trim();
121
+ return id ? id : null;
122
+ }
123
+
124
+ function propertyIdList(value: unknown) {
125
+ if (!Array.isArray(value)) return [];
126
+ const seen = new Set<string>();
127
+ const ids: string[] = [];
128
+ for (const item of value) {
129
+ const id = propertyIdOrNull(item);
130
+ if (!id || seen.has(id)) continue;
131
+ seen.add(id);
132
+ ids.push(id);
133
+ if (ids.length === MAX_ITEM_LAYOUT_META_PROPERTIES) break;
134
+ }
135
+ return ids;
136
+ }
137
+
138
+ function record(value: unknown): Record<string, unknown> {
139
+ return value && typeof value === "object" && !Array.isArray(value)
140
+ ? (value as Record<string, unknown>)
141
+ : {};
142
+ }
143
+
144
+ /**
145
+ * Reads a saved item layout, filling anything missing. Null means the view
146
+ * keeps its default rows.
147
+ */
148
+ export function normalizeContentDatabaseItemLayout(
149
+ value: unknown,
150
+ ): ContentDatabaseItemLayout | null {
151
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
152
+ const raw = value as Record<string, unknown>;
153
+ const leading = record(raw.leading);
154
+ const line2 = record(raw.line2);
155
+ const kind = CONTENT_DATABASE_ITEM_LEADING_KINDS.includes(
156
+ leading.kind as ContentDatabaseItemLeadingKind,
157
+ )
158
+ ? (leading.kind as ContentDatabaseItemLeadingKind)
159
+ : "page_icon";
160
+ const lines = CONTENT_DATABASE_ITEM_LINE2_LINES.includes(
161
+ line2.lines as 1 | 2 | 3,
162
+ )
163
+ ? (line2.lines as 1 | 2 | 3)
164
+ : 1;
165
+ return {
166
+ leading: {
167
+ kind,
168
+ propertyId: LEADING_PROPERTY_TYPES[kind]
169
+ ? propertyIdOrNull(leading.propertyId)
170
+ : null,
171
+ },
172
+ titleWeight: CONTENT_DATABASE_ITEM_TITLE_WEIGHTS.includes(
173
+ raw.titleWeight as ContentDatabaseItemTitleWeight,
174
+ )
175
+ ? (raw.titleWeight as ContentDatabaseItemTitleWeight)
176
+ : "medium",
177
+ line2: { propertyId: propertyIdOrNull(line2.propertyId), lines },
178
+ line3: propertyIdList(raw.line3),
179
+ trailing: propertyIdList(raw.trailing),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The `itemLayout` field of a normalized view. An explicit null is kept so a
185
+ * reset to default rows overrides the layout saved on the server; a missing
186
+ * or unreadable value is left out so saves that don't know about layouts
187
+ * keep the saved one.
188
+ */
189
+ export function contentDatabaseItemLayoutField(value: unknown): {
190
+ itemLayout?: ContentDatabaseItemLayout | null;
191
+ } {
192
+ if (value === null) return { itemLayout: null };
193
+ const itemLayout = normalizeContentDatabaseItemLayout(value);
194
+ return itemLayout ? { itemLayout } : {};
195
+ }
196
+
197
+ /** Carries the saved layout into a full-view save that left it out. */
198
+ export function withSavedContentDatabaseItemLayout<
199
+ T extends { id: string; itemLayout?: ContentDatabaseItemLayout | null },
200
+ >(
201
+ view: T,
202
+ savedViews: readonly {
203
+ id: string;
204
+ itemLayout?: ContentDatabaseItemLayout | null;
205
+ }[],
206
+ ): T {
207
+ if (view.itemLayout !== undefined) return view;
208
+ const saved = savedViews.find((candidate) => candidate.id === view.id);
209
+ return saved?.itemLayout === undefined
210
+ ? view
211
+ : { ...view, itemLayout: saved.itemLayout };
212
+ }
213
+
214
+ export interface ResolvedContentDatabaseItemLayout<P> {
215
+ leading: { kind: ContentDatabaseItemLeadingKind; property: P | null };
216
+ titleWeight: ContentDatabaseItemTitleWeight;
217
+ line2: { property: P | null; lines: 1 | 2 | 3 };
218
+ line3: P[];
219
+ trailing: P[];
220
+ }
221
+
222
+ /**
223
+ * Matches a layout's property IDs to the collection's current properties.
224
+ * Missing or mismatched properties drop out, and a checkbox or status slot
225
+ * without a usable property falls back to the page icon.
226
+ */
227
+ export function resolveContentDatabaseItemLayout<
228
+ P extends ContentDatabaseItemLayoutPropertyLike,
229
+ >(
230
+ layout: ContentDatabaseItemLayout,
231
+ properties: readonly P[],
232
+ ): ResolvedContentDatabaseItemLayout<P> {
233
+ const byId = new Map(
234
+ showableProperties(properties).map((property) => [
235
+ property.definition.id,
236
+ property,
237
+ ]),
238
+ );
239
+ const leadingCandidates = itemLayoutLeadingCandidates(
240
+ properties,
241
+ layout.leading.kind,
242
+ );
243
+ const leadingProperty = LEADING_PROPERTY_TYPES[layout.leading.kind]
244
+ ? layout.leading.propertyId
245
+ ? (leadingCandidates.find(
246
+ (property) => property.definition.id === layout.leading.propertyId,
247
+ ) ?? null)
248
+ : (leadingCandidates[0] ?? null)
249
+ : null;
250
+ const line2Candidates = new Set(itemLayoutLine2Candidates(properties));
251
+ const line2Property = layout.line2.propertyId
252
+ ? (byId.get(layout.line2.propertyId) ?? null)
253
+ : null;
254
+ const meta = (ids: readonly string[]) =>
255
+ ids.flatMap((id) => {
256
+ const property = byId.get(id);
257
+ return property && property.definition.type !== "blocks"
258
+ ? [property]
259
+ : [];
260
+ });
261
+ return {
262
+ leading: {
263
+ kind:
264
+ LEADING_PROPERTY_TYPES[layout.leading.kind] && !leadingProperty
265
+ ? "page_icon"
266
+ : layout.leading.kind,
267
+ property: leadingProperty,
268
+ },
269
+ titleWeight: layout.titleWeight,
270
+ line2: {
271
+ property:
272
+ line2Property && line2Candidates.has(line2Property)
273
+ ? line2Property
274
+ : null,
275
+ lines: layout.line2.lines,
276
+ },
277
+ line3: meta(layout.line3),
278
+ trailing: meta(layout.trailing),
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Lists what is wrong with a layout's property references, for callers that
284
+ * should reject a bad layout instead of rendering around it.
285
+ */
286
+ export function contentDatabaseItemLayoutReferenceErrors(
287
+ layout: ContentDatabaseItemLayout,
288
+ properties: readonly ContentDatabaseItemLayoutPropertyLike[],
289
+ ): string[] {
290
+ const errors: string[] = [];
291
+ const byId = new Map(
292
+ showableProperties(properties).map((property) => [
293
+ property.definition.id,
294
+ property,
295
+ ]),
296
+ );
297
+ const leadingType = LEADING_PROPERTY_TYPES[layout.leading.kind];
298
+ if (leadingType) {
299
+ const property = layout.leading.propertyId
300
+ ? byId.get(layout.leading.propertyId)
301
+ : itemLayoutLeadingCandidates(properties, layout.leading.kind)[0];
302
+ if (!property) {
303
+ errors.push(
304
+ layout.leading.propertyId
305
+ ? `leading.propertyId references unavailable property "${layout.leading.propertyId}".`
306
+ : `A ${layout.leading.kind} leading slot needs a ${leadingType} property.`,
307
+ );
308
+ } else if (property.definition.type !== leadingType) {
309
+ errors.push(
310
+ `leading.propertyId must reference a ${leadingType} property for a ${layout.leading.kind} leading slot.`,
311
+ );
312
+ }
313
+ }
314
+ if (layout.line2.propertyId) {
315
+ const property = byId.get(layout.line2.propertyId);
316
+ if (!property) {
317
+ errors.push(
318
+ `line2.propertyId references unavailable property "${layout.line2.propertyId}".`,
319
+ );
320
+ } else if (!itemLayoutLine2Candidates([property]).length) {
321
+ errors.push(
322
+ "line2.propertyId must reference a text, URL, or non-body Blocks property.",
323
+ );
324
+ }
325
+ }
326
+ for (const slot of ["line3", "trailing"] as const) {
327
+ for (const id of layout[slot]) {
328
+ const property = byId.get(id);
329
+ if (!property) {
330
+ errors.push(`${slot} references unavailable property "${id}".`);
331
+ } else if (property.definition.type === "blocks") {
332
+ errors.push(`${slot} cannot show the Blocks property "${id}".`);
333
+ }
334
+ }
335
+ }
336
+ return errors;
337
+ }
338
+
339
+ /**
340
+ * Drops slot references the rows can't show (deleted, always-hidden, or the
341
+ * wrong type), so a stale saved slot never blocks a later edit.
342
+ */
343
+ export function pruneContentDatabaseItemLayout(
344
+ layout: ContentDatabaseItemLayout,
345
+ properties: readonly ContentDatabaseItemLayoutPropertyLike[],
346
+ ): ContentDatabaseItemLayout {
347
+ const resolved = resolveContentDatabaseItemLayout(layout, properties);
348
+ const ids = (list: readonly ContentDatabaseItemLayoutPropertyLike[]) =>
349
+ list.map((property) => property.definition.id);
350
+ return {
351
+ leading: {
352
+ kind: resolved.leading.kind,
353
+ propertyId: resolved.leading.property ? layout.leading.propertyId : null,
354
+ },
355
+ titleWeight: layout.titleWeight,
356
+ line2: {
357
+ propertyId: resolved.line2.property?.definition.id ?? null,
358
+ lines: layout.line2.lines,
359
+ },
360
+ line3: ids(resolved.line3),
361
+ trailing: ids(resolved.trailing),
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Drops slot references to properties that no longer exist. References to
367
+ * hidden or retyped properties stay saved, because those changes can be
368
+ * undone; the rows skip them meanwhile.
369
+ */
370
+ export function dropDeletedContentDatabaseItemLayoutReferences(
371
+ layout: ContentDatabaseItemLayout,
372
+ properties: readonly ContentDatabaseItemLayoutPropertyLike[],
373
+ ): ContentDatabaseItemLayout {
374
+ const exists = new Set(properties.map((property) => property.definition.id));
375
+ const kept = (id: string | null) => (id && exists.has(id) ? id : null);
376
+ return {
377
+ leading:
378
+ layout.leading.propertyId && !kept(layout.leading.propertyId)
379
+ ? { kind: "page_icon", propertyId: null }
380
+ : layout.leading,
381
+ titleWeight: layout.titleWeight,
382
+ line2: {
383
+ propertyId: kept(layout.line2.propertyId),
384
+ lines: layout.line2.lines,
385
+ },
386
+ line3: layout.line3.filter((id) => exists.has(id)),
387
+ trailing: layout.trailing.filter((id) => exists.has(id)),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Line 3 or End after someone picks a property in the slot's menu, or
393
+ * Nothing (null). Saved properties the menu doesn't offer, such as ones
394
+ * hidden in this view, stay for when they're shown again; they give up their
395
+ * place only when the slot is full.
396
+ */
397
+ export function toggleContentDatabaseItemLayoutMetaProperty(
398
+ savedIds: readonly string[],
399
+ offeredIds: readonly string[],
400
+ id: string | null,
401
+ ): string[] {
402
+ const offered = new Set(offeredIds);
403
+ const next =
404
+ id === null
405
+ ? savedIds.filter((saved) => !offered.has(saved))
406
+ : savedIds.includes(id)
407
+ ? savedIds.filter((saved) => saved !== id)
408
+ : [...savedIds, id];
409
+ for (
410
+ let index = next.length - 1;
411
+ index >= 0 && next.length > MAX_ITEM_LAYOUT_META_PROPERTIES;
412
+ index -= 1
413
+ ) {
414
+ if (!offered.has(next[index])) next.splice(index, 1);
415
+ }
416
+ return next;
417
+ }
418
+
419
+ /**
420
+ * The layout a full-view save stores. References the view already saved
421
+ * stay, hidden or retyped or not; a new reference the rows can't show (the
422
+ * wrong type, or hidden) is dropped, as is any reference to a deleted
423
+ * property. A full save never fails over a slot.
424
+ */
425
+ export function savableContentDatabaseItemLayout(
426
+ layout: ContentDatabaseItemLayout,
427
+ saved: ContentDatabaseItemLayout | null,
428
+ properties: readonly ContentDatabaseItemLayoutPropertyLike[],
429
+ hiddenPropertyIds: readonly string[] = [],
430
+ ): ContentDatabaseItemLayout {
431
+ const hidden = new Set(hiddenPropertyIds);
432
+ const showable = pruneContentDatabaseItemLayout(
433
+ layout,
434
+ properties.filter((property) => !hidden.has(property.definition.id)),
435
+ );
436
+ const savedLeading =
437
+ saved?.leading.kind === layout.leading.kind &&
438
+ saved.leading.propertyId === layout.leading.propertyId;
439
+ const meta = (slot: "line3" | "trailing") =>
440
+ layout[slot].filter(
441
+ (id) => saved?.[slot].includes(id) || showable[slot].includes(id),
442
+ );
443
+ return dropDeletedContentDatabaseItemLayoutReferences(
444
+ {
445
+ leading: savedLeading ? layout.leading : showable.leading,
446
+ titleWeight: layout.titleWeight,
447
+ line2: {
448
+ propertyId:
449
+ saved?.line2.propertyId === layout.line2.propertyId
450
+ ? layout.line2.propertyId
451
+ : showable.line2.propertyId,
452
+ lines: layout.line2.lines,
453
+ },
454
+ line3: meta("line3"),
455
+ trailing: meta("trailing"),
456
+ },
457
+ properties,
458
+ );
459
+ }
460
+
461
+ /**
462
+ * The layout someone starts editing from on a view with no saved layout. It
463
+ * matches the default rows: page icon, title, and the first four properties.
464
+ * Line 2 is the only slot that shows a Blocks field, so one of those goes
465
+ * there. The page body itself has no slot yet.
466
+ */
467
+ export function startingContentDatabaseItemLayout(
468
+ visibleProperties: readonly ContentDatabaseItemLayoutPropertyLike[],
469
+ ): ContentDatabaseItemLayout {
470
+ const shown = visibleProperties.slice(0, 4);
471
+ const blocks = itemLayoutLine2Candidates(shown).find(
472
+ (property) => property.definition.type === "blocks",
473
+ );
474
+ return {
475
+ leading: { kind: "page_icon", propertyId: null },
476
+ titleWeight: "medium",
477
+ line2: { propertyId: blocks?.definition.id ?? null, lines: 1 },
478
+ line3: [],
479
+ trailing: itemLayoutMetaCandidates(shown).map(
480
+ (property) => property.definition.id,
481
+ ),
482
+ };
483
+ }
484
+
485
+ export interface ContentDatabaseItemLayoutPreset {
486
+ id: ContentDatabaseItemLayoutPresetId;
487
+ /** Null for Default, which keeps the view's default rows. */
488
+ layout: ContentDatabaseItemLayout | null;
489
+ /** The property type the preset needs but the collection lacks. */
490
+ missingPropertyType: "checkbox" | "status" | null;
491
+ }
492
+
493
+ function firstOfType<P extends ContentDatabaseItemLayoutPropertyLike>(
494
+ properties: readonly P[],
495
+ ...types: DocumentPropertyType[]
496
+ ) {
497
+ for (const type of types) {
498
+ const match = properties.find(
499
+ (property) => property.definition.type === type,
500
+ );
501
+ if (match) return match.definition.id;
502
+ }
503
+ return null;
504
+ }
505
+
506
+ /** Named starting points for a list's rows, built from the collection's properties. */
507
+ export function contentDatabaseItemLayoutPresets(
508
+ properties: readonly ContentDatabaseItemLayoutPropertyLike[],
509
+ ): ContentDatabaseItemLayoutPreset[] {
510
+ const shown = showableProperties(properties);
511
+ const checkbox = firstOfType(shown, "checkbox");
512
+ const status = firstOfType(shown, "status");
513
+ const text = itemLayoutLine2Candidates(shown)[0]?.definition.id ?? null;
514
+ const date = firstOfType(shown, "date");
515
+ const person = firstOfType(shown, "person");
516
+ const tag = firstOfType(shown, "multi_select", "select");
517
+ const ids = (...list: Array<string | null>) =>
518
+ list.filter((id): id is string => !!id);
519
+
520
+ return [
521
+ { id: "default", layout: null, missingPropertyType: null },
522
+ {
523
+ id: "checklist",
524
+ missingPropertyType: checkbox ? null : "checkbox",
525
+ layout: {
526
+ leading: { kind: "checkbox", propertyId: checkbox },
527
+ titleWeight: "bold",
528
+ line2: { propertyId: text, lines: 2 },
529
+ line3: [],
530
+ trailing: [],
531
+ },
532
+ },
533
+ {
534
+ id: "reminders",
535
+ missingPropertyType: checkbox ? null : "checkbox",
536
+ layout: {
537
+ leading: { kind: "checkbox", propertyId: checkbox },
538
+ titleWeight: "medium",
539
+ line2: { propertyId: text, lines: 1 },
540
+ line3: ids(date, person, tag),
541
+ trailing: [],
542
+ },
543
+ },
544
+ {
545
+ id: "tracker",
546
+ missingPropertyType: status ? null : "status",
547
+ layout: {
548
+ leading: { kind: "status", propertyId: status },
549
+ titleWeight: "regular",
550
+ line2: { propertyId: null, lines: 1 },
551
+ line3: [],
552
+ trailing: ids(tag, person, date),
553
+ },
554
+ },
555
+ {
556
+ id: "digest",
557
+ missingPropertyType: null,
558
+ layout: {
559
+ leading: { kind: "page_icon", propertyId: null },
560
+ titleWeight: "bold",
561
+ line2: { propertyId: text, lines: 3 },
562
+ line3: ids(status, date, tag),
563
+ trailing: [],
564
+ },
565
+ },
566
+ ];
567
+ }
568
+
569
+ function sameLayout(
570
+ a: ContentDatabaseItemLayout | null,
571
+ b: ContentDatabaseItemLayout | null,
572
+ ) {
573
+ return (
574
+ JSON.stringify(normalizeContentDatabaseItemLayout(a)) ===
575
+ JSON.stringify(normalizeContentDatabaseItemLayout(b))
576
+ );
577
+ }
578
+
579
+ /** The preset a layout currently matches, or null for a custom layout. */
580
+ export function matchingContentDatabaseItemLayoutPreset(
581
+ layout: ContentDatabaseItemLayout | null | undefined,
582
+ properties: readonly ContentDatabaseItemLayoutPropertyLike[],
583
+ ): ContentDatabaseItemLayoutPresetId | null {
584
+ const current = layout ?? null;
585
+ return (
586
+ contentDatabaseItemLayoutPresets(properties).find(
587
+ (preset) =>
588
+ !preset.missingPropertyType && sameLayout(preset.layout, current),
589
+ )?.id ?? null
590
+ );
591
+ }