@crossworks/content-core 0.230.43

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 (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Unit tests for listBlocks — the TOC extraction that powers
3
+ * `page_blocks_list` (the agent's "what's in this page?" lookup).
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import { ensureBlockIds } from './block-ids';
8
+ import { listBlocks } from './block-list';
9
+
10
+ function blocked(doc: Record<string, unknown>): Record<string, unknown> {
11
+ // Always run through ensureBlockIds so the listings have stable ids,
12
+ // matching the real call path (markdownToDoc / getPage / saveDraft).
13
+ return ensureBlockIds(doc);
14
+ }
15
+
16
+ describe('listBlocks', () => {
17
+ it('lists top-level blocks in document order with depth=1', () => {
18
+ const doc = blocked({
19
+ type: 'doc',
20
+ content: [
21
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Hi' }] },
22
+ { type: 'paragraph', content: [{ type: 'text', text: 'World' }] },
23
+ { type: 'horizontalRule' },
24
+ ],
25
+ });
26
+ const blocks = listBlocks(doc);
27
+ expect(blocks).toHaveLength(3);
28
+ expect(blocks.map((b) => b.kind)).toEqual(['heading', 'paragraph', 'horizontalRule']);
29
+ expect(blocks.every((b) => b.depth === 1)).toBe(true);
30
+ expect(blocks.every((b) => typeof b.id === 'string' && b.id.length > 8)).toBe(true);
31
+ });
32
+
33
+ it('captures previews from text content, single-line + trimmed', () => {
34
+ const doc = blocked({
35
+ type: 'doc',
36
+ content: [
37
+ {
38
+ type: 'paragraph',
39
+ content: [
40
+ { type: 'text', text: ' Hello ' },
41
+ { type: 'text', text: ' world\n', marks: [{ type: 'bold' }] },
42
+ ],
43
+ },
44
+ ],
45
+ });
46
+ const [p] = listBlocks(doc);
47
+ expect(p?.preview).toBe('Hello world');
48
+ });
49
+
50
+ it('truncates previews with an ellipsis past the cap', () => {
51
+ const long = 'x'.repeat(200);
52
+ const doc = blocked({
53
+ type: 'doc',
54
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: long }] }],
55
+ });
56
+ const [p] = listBlocks(doc, { previewChars: 50 });
57
+ expect(p?.preview.length).toBeLessThanOrEqual(50);
58
+ expect(p?.preview.endsWith('…')).toBe(true);
59
+ });
60
+
61
+ it('includes meta — heading level, code language, callout variant', () => {
62
+ const doc = blocked({
63
+ type: 'doc',
64
+ content: [
65
+ { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'H' }] },
66
+ {
67
+ type: 'codeBlock',
68
+ attrs: { language: 'typescript' },
69
+ content: [{ type: 'text', text: 'const x = 1' }],
70
+ },
71
+ {
72
+ type: 'callout',
73
+ attrs: { variant: 'warning' },
74
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'careful' }] }],
75
+ },
76
+ ],
77
+ });
78
+ const blocks = listBlocks(doc);
79
+ expect(blocks[0]!.meta).toEqual({ level: 2 });
80
+ expect(blocks[1]!.meta).toEqual({ language: 'typescript' });
81
+ expect(blocks[2]!.meta).toEqual({ variant: 'warning' });
82
+ });
83
+
84
+ it('lists a diagram block with its declared type as meta', () => {
85
+ const doc = blocked({
86
+ type: 'doc',
87
+ content: [
88
+ { type: 'diagram', attrs: { source: 'flowchart LR\n A --> B' } },
89
+ { type: 'diagram', attrs: { source: '---\ntitle: Plan\n---\nmindmap\n root' } },
90
+ ],
91
+ });
92
+ const blocks = listBlocks(doc);
93
+ expect(blocks[0]!.kind).toBe('diagram');
94
+ expect(blocks[0]!.id).toBeTruthy();
95
+ expect(blocks[0]!.meta).toEqual({ diagram: 'flowchart' });
96
+ // Mermaid frontmatter is skipped when reading the declared type.
97
+ expect(blocks[1]!.meta).toEqual({ diagram: 'mindmap' });
98
+ });
99
+
100
+ it('diagram meta skips %% directives and yields none for unclosed frontmatter', () => {
101
+ const doc = blocked({
102
+ type: 'doc',
103
+ content: [
104
+ { type: 'diagram', attrs: { source: '%%{init: {"theme":"base"}}%%\nflowchart TD\n A' } },
105
+ { type: 'diagram', attrs: { source: '---\ntitle: Broken\nmindmap\n root' } },
106
+ { type: 'diagram', attrs: { source: '' } },
107
+ ],
108
+ });
109
+ const blocks = listBlocks(doc);
110
+ expect(blocks[0]!.meta).toEqual({ diagram: 'flowchart' });
111
+ expect(blocks[1]!.meta).toBeUndefined();
112
+ expect(blocks[2]!.meta).toBeUndefined();
113
+ });
114
+
115
+ it('walks into containers — callout body shows at depth=2', () => {
116
+ const doc = blocked({
117
+ type: 'doc',
118
+ content: [
119
+ {
120
+ type: 'callout',
121
+ attrs: { variant: 'info' },
122
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'inside' }] }],
123
+ },
124
+ ],
125
+ });
126
+ const blocks = listBlocks(doc);
127
+ expect(blocks).toHaveLength(2); // callout + inner paragraph
128
+ expect(blocks[0]).toMatchObject({ kind: 'callout', depth: 1 });
129
+ expect(blocks[1]).toMatchObject({ kind: 'paragraph', depth: 2, preview: 'inside' });
130
+ });
131
+
132
+ it('respects maxDepth — only top-level when maxDepth=1', () => {
133
+ const doc = blocked({
134
+ type: 'doc',
135
+ content: [
136
+ {
137
+ type: 'callout',
138
+ attrs: { variant: 'info' },
139
+ content: [
140
+ { type: 'paragraph', content: [{ type: 'text', text: 'inside' }] },
141
+ { type: 'paragraph', content: [{ type: 'text', text: 'too' }] },
142
+ ],
143
+ },
144
+ { type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
145
+ ],
146
+ });
147
+ const blocks = listBlocks(doc, { maxDepth: 1 });
148
+ expect(blocks).toHaveLength(2); // callout + 'after' paragraph, no inner
149
+ expect(blocks.map((b) => b.kind)).toEqual(['callout', 'paragraph']);
150
+ });
151
+
152
+ it('columnList → columns → inner blocks all listed in document order', () => {
153
+ const doc = blocked({
154
+ type: 'doc',
155
+ content: [
156
+ {
157
+ type: 'columnList',
158
+ content: [
159
+ {
160
+ type: 'column',
161
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'L' }] }],
162
+ },
163
+ {
164
+ type: 'column',
165
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'R' }] }],
166
+ },
167
+ ],
168
+ },
169
+ ],
170
+ });
171
+ const blocks = listBlocks(doc);
172
+ expect(blocks.map((b) => `${b.kind}@${b.depth}:${b.preview || '-'}`)).toEqual([
173
+ 'columnList@1:L R',
174
+ 'column@2:L',
175
+ 'paragraph@3:L',
176
+ 'column@2:R',
177
+ 'paragraph@3:R',
178
+ ]);
179
+ });
180
+
181
+ it('kinds filter returns only matching blocks — but walker still descends', () => {
182
+ const doc = blocked({
183
+ type: 'doc',
184
+ content: [
185
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'H' }] },
186
+ { type: 'paragraph', content: [{ type: 'text', text: 'top P' }] },
187
+ {
188
+ type: 'callout',
189
+ attrs: { variant: 'info' },
190
+ content: [
191
+ { type: 'paragraph', content: [{ type: 'text', text: 'inner P' }] },
192
+ {
193
+ type: 'blockquote',
194
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'quote' }] }],
195
+ },
196
+ ],
197
+ },
198
+ {
199
+ type: 'blockquote',
200
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'top quote' }] }],
201
+ },
202
+ ],
203
+ });
204
+
205
+ // Only blockquotes — should return both (one nested in a callout, one top).
206
+ const blockquotes = listBlocks(doc, { kinds: ['blockquote'] });
207
+ expect(blockquotes.map((b) => b.kind)).toEqual(['blockquote', 'blockquote']);
208
+ expect(blockquotes[0]!.preview).toBe('quote'); // the nested one (first in doc order)
209
+ expect(blockquotes[1]!.preview).toBe('top quote');
210
+
211
+ // Multi-kind filter.
212
+ const both = listBlocks(doc, { kinds: ['heading', 'blockquote'] });
213
+ expect(both.map((b) => b.kind)).toEqual(['heading', 'blockquote', 'blockquote']);
214
+
215
+ // No filter — everything.
216
+ const all = listBlocks(doc);
217
+ expect(all.length).toBeGreaterThan(4); // heading + p + callout + inner p + blockquote + inner p + top blockquote + inner p
218
+
219
+ // Empty filter array = no filter (treated as "all kinds").
220
+ const emptyFilter = listBlocks(doc, { kinds: [] });
221
+ expect(emptyFilter.length).toBe(all.length);
222
+ });
223
+
224
+ it('stays compact — 50–80 bytes per typical block in JSON', () => {
225
+ const doc = blocked({
226
+ type: 'doc',
227
+ content: Array.from({ length: 50 }, (_, i) => ({
228
+ type: 'paragraph',
229
+ content: [{ type: 'text', text: `Block ${i} with some moderately long content here.` }],
230
+ })),
231
+ });
232
+ const blocks = listBlocks(doc);
233
+ const serialized = JSON.stringify(blocks);
234
+ const bytesPerBlock = serialized.length / blocks.length;
235
+ expect(blocks).toHaveLength(50);
236
+ // Each entry: id (36) + kind (~9) + depth (1) + preview (~60) + JSON
237
+ // overhead. Generous upper bound — if we ever blow past this something
238
+ // changed in the shape and the agent's tool-result budget is at risk.
239
+ expect(bytesPerBlock).toBeLessThan(180);
240
+ });
241
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Block TOC extraction — flat listing of every addressable block in a
3
+ * ProseMirror doc, with type / depth / id / short text preview. Powers
4
+ * `page_blocks_list` (the agent's "what's in this page?" tool) and the
5
+ * Phase 3a editor's block picker.
6
+ *
7
+ * Designed to be cheap to send to a model: 50–80 bytes per block in
8
+ * JSON, so a 200-block page is ~10–15 KB total — well under the
9
+ * inline tool-result cap. Agent reads the TOC to decide WHICH blocks
10
+ * to touch, then fetches only those (Phase 2b mutation tools).
11
+ *
12
+ * Pure, no DB. Companion to block-ids.ts — and imports the addressable
13
+ * block-type set from there so the two stay in lockstep automatically.
14
+ * A new block type added to block-ids.ts is picked up here for free.
15
+ */
16
+
17
+ import { BLOCK_NODE_TYPES as BLOCK_TYPES } from './block-ids';
18
+
19
+ export type BlockListEntry = {
20
+ /** Stable per-block id (assigned by ensureBlockIds). May be the
21
+ * empty string if the doc was somehow built without injection — the
22
+ * walker is defensive and still lists the block. */
23
+ id: string;
24
+ /** PM node type (e.g. 'heading', 'paragraph', 'callout'). */
25
+ kind: string;
26
+ /** Tree depth: 1 = direct child of doc, 2 = inside a container, etc. */
27
+ depth: number;
28
+ /** First ~80 chars of textContent, single-line, trimmed. Empty
29
+ * string for purely structural nodes (horizontalRule, table without
30
+ * inner text yet, etc.). */
31
+ preview: string;
32
+ /** For headings: their level (1/2/3). For codeBlock: language. For
33
+ * callout: variant. Helps the model choose. Omitted when N/A. */
34
+ meta?: Record<string, unknown>;
35
+ };
36
+
37
+ export type ListBlocksOptions = {
38
+ /** If set, only list blocks at this depth or shallower. Useful when
39
+ * the model just wants a high-level outline (depth: 1) or top-level
40
+ * + first-nested (depth: 2). Default: unlimited. */
41
+ maxDepth?: number;
42
+ /** Preview character cap. Default 80. */
43
+ previewChars?: number;
44
+ /** If set, only blocks whose `kind` is in this set are listed (but
45
+ * the walker still DESCENDS through other types — a paragraph inside
46
+ * a callout is still findable when filtering by 'paragraph'). Powers
47
+ * targeted edits: 'find every blockquote', 'list every heading',
48
+ * etc., without the spill risk of unfiltered output on a large doc. */
49
+ kinds?: ReadonlyArray<string>;
50
+ };
51
+
52
+ type AnyNode = {
53
+ type?: string;
54
+ attrs?: Record<string, unknown> | null;
55
+ content?: AnyNode[];
56
+ text?: string;
57
+ };
58
+
59
+ /**
60
+ * Walk the doc, return flat block list in document order. Container
61
+ * blocks appear BEFORE their children (so the agent sees structure
62
+ * top-down — heading first, then the paragraphs under it).
63
+ */
64
+ export function listBlocks(
65
+ doc: Record<string, unknown>,
66
+ opts: ListBlocksOptions = {},
67
+ ): BlockListEntry[] {
68
+ const maxDepth = opts.maxDepth ?? Infinity;
69
+ const previewChars = opts.previewChars ?? 80;
70
+ const kindFilter = opts.kinds && opts.kinds.length > 0 ? new Set(opts.kinds) : null;
71
+ const out: BlockListEntry[] = [];
72
+ walk(doc as AnyNode, 0, out, maxDepth, previewChars, kindFilter);
73
+ return out;
74
+ }
75
+
76
+ function walk(
77
+ node: AnyNode,
78
+ depth: number,
79
+ out: BlockListEntry[],
80
+ maxDepth: number,
81
+ previewChars: number,
82
+ kindFilter: Set<string> | null,
83
+ ): void {
84
+ if (!node || typeof node !== 'object') return;
85
+ const kind = node.type;
86
+ if (kind && BLOCK_TYPES.has(kind)) {
87
+ // depth+1 because the doc root itself is depth 0; its first children
88
+ // are depth 1 (the natural reading: "block 1, block 2, …" at the
89
+ // top of the page).
90
+ const blockDepth = depth + 1;
91
+ // Two gates to PUSH: within maxDepth AND (no kind filter OR kind matches).
92
+ // The recursion always descends regardless of kind — a paragraph inside
93
+ // a filtered-out callout still gets listed if its own kind passes.
94
+ if (blockDepth <= maxDepth && (!kindFilter || kindFilter.has(kind))) {
95
+ out.push({
96
+ id: typeof node.attrs?.id === 'string' ? node.attrs.id : '',
97
+ kind,
98
+ depth: blockDepth,
99
+ preview: makePreview(node, previewChars),
100
+ ...(blockMeta(kind, node) ? { meta: blockMeta(kind, node)! } : {}),
101
+ });
102
+ }
103
+ // Always recurse INTO the block, even if we're at maxDepth — children
104
+ // beyond the cap simply don't get listed, but we still walk them in
105
+ // case of cap=1 (only top-level) the walker exits at the children
106
+ // loop without adding more entries. That's fine.
107
+ if (Array.isArray(node.content) && blockDepth < maxDepth) {
108
+ for (const child of node.content) {
109
+ walk(child, blockDepth, out, maxDepth, previewChars, kindFilter);
110
+ }
111
+ }
112
+ return;
113
+ }
114
+ // Non-block (doc root, text, marks): recurse children with same depth.
115
+ if (Array.isArray(node.content)) {
116
+ for (const child of node.content) {
117
+ walk(child, depth, out, maxDepth, previewChars, kindFilter);
118
+ }
119
+ }
120
+ }
121
+
122
+ function makePreview(node: AnyNode, max: number): string {
123
+ const text = collectText(node).replace(/\s+/g, ' ').trim();
124
+ if (text.length <= max) return text;
125
+ return text.slice(0, max - 1).trimEnd() + '…';
126
+ }
127
+
128
+ function collectText(node: AnyNode): string {
129
+ if (!node || typeof node !== 'object') return '';
130
+ if (typeof node.text === 'string') return node.text;
131
+ if (!Array.isArray(node.content)) return '';
132
+ let acc = '';
133
+ for (const child of node.content) {
134
+ const t = collectText(child);
135
+ if (t) {
136
+ if (acc && !acc.endsWith(' ')) acc += ' ';
137
+ acc += t;
138
+ }
139
+ }
140
+ return acc;
141
+ }
142
+
143
+ function blockMeta(kind: string, node: AnyNode): Record<string, unknown> | null {
144
+ const attrs = node.attrs ?? {};
145
+ switch (kind) {
146
+ case 'heading':
147
+ return typeof attrs.level === 'number' ? { level: attrs.level } : null;
148
+ case 'codeBlock':
149
+ return typeof attrs.language === 'string' && attrs.language
150
+ ? { language: attrs.language }
151
+ : null;
152
+ case 'callout':
153
+ return typeof attrs.variant === 'string' ? { variant: attrs.variant } : null;
154
+ case 'aside':
155
+ return typeof attrs.color === 'string' ? { color: attrs.color } : null;
156
+ case 'taskItem':
157
+ return typeof attrs.checked === 'boolean' ? { checked: attrs.checked } : null;
158
+ case 'image':
159
+ case 'pageImage':
160
+ return typeof attrs.alt === 'string' && attrs.alt ? { alt: attrs.alt } : null;
161
+ case 'diagram': {
162
+ // Surface the declared diagram type (flowchart, sequenceDiagram, mindmap…)
163
+ // — the first meaningful word: skip ---frontmatter--- and %%…%% directive/
164
+ // comment lines; an unclosed frontmatter yields no meta rather than '---'.
165
+ if (typeof attrs.source !== 'string') return null;
166
+ const body = attrs.source.replace(/^\s*---[\s\S]*?---\s*/, '');
167
+ const first = body
168
+ .split('\n')
169
+ .map((l) => l.trim())
170
+ .find((l) => l && !l.startsWith('%%'));
171
+ const word = first?.split(/[\s:;{]/, 1)[0];
172
+ return word && word !== '---' ? { diagram: word } : null;
173
+ }
174
+ default:
175
+ return null;
176
+ }
177
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Pure shape + format helpers for contacts. NO database imports — this is the
3
+ * module the browser-side `/contacts` client pulls from (via the
4
+ * `@mantle/content/contacts-format` subpath) to avoid dragging `postgres` /
5
+ * `@mantle/db` into the client bundle.
6
+ *
7
+ * The DB-using CRUD lives in `./contacts.ts`, which re-exports these so
8
+ * server-side callers can keep importing from `@mantle/content`.
9
+ */
10
+
11
+ /** Methods we count outbound contact attempts by. Open-ended on the data side
12
+ * (the jsonb just stores keys), but typed here for the call sites we wire
13
+ * ourselves so a typo becomes a compile error. Add new entries as we add
14
+ * surfaces. */
15
+ export type ContactMethod = 'email' | 'sms';
16
+
17
+ export type ContactCounts = Partial<Record<string, number>>;
18
+ export type ContactLastAt = Partial<Record<string, string>>;
19
+
20
+ export type ContactRow = {
21
+ id: string;
22
+ title: string;
23
+ firstName: string;
24
+ lastName: string;
25
+ /** Organisation name. Independent of person name — supports both
26
+ * "John Smith" (no company), "Modular" (company-only, e.g. a supplier),
27
+ * and "John Smith @ Modular" (both). */
28
+ company: string;
29
+ /** Every email entry on the contact. Each is either a full address
30
+ * (`alex@example.com`) or a `@domain` wildcard (`@example.com` = all mail
31
+ * from that domain). The inbound gate matches against both; the outbound
32
+ * send allowlist uses concrete addresses only (see `partitionEmailEntries`). */
33
+ emails: string[];
34
+ /** Derived convenience = `emails[0] ?? ''`. Kept so existing list rows /
35
+ * tool projections that read a single `email` keep working unchanged. */
36
+ email: string;
37
+ countryCode: string;
38
+ cell: string;
39
+ /** E.164 normalised number, e.g. "+27760810774". Empty when no cell on file. */
40
+ cellE164: string;
41
+ /** Human-formatted cell with country code, e.g. "+27 76 081 0774". */
42
+ cellFormatted: string;
43
+ description: string;
44
+ tags: string[];
45
+ summary: string | null;
46
+ /** Per-method count of outbound contact attempts (bumped on send success).
47
+ * Empty object when never contacted; missing keys read as 0. */
48
+ contactCounts: ContactCounts;
49
+ /** Per-method ISO timestamp of the most recent outbound. Missing keys = never. */
50
+ lastContactedAt: ContactLastAt;
51
+ /** Team membership (see @mantle/content/team-tokens). Null when the contact
52
+ * is not a team member; the plaintext token is NEVER part of this row —
53
+ * it's returned once at mint and only its hash is stored. */
54
+ team: { since: string; lastUsedAt: string | null } | null;
55
+ createdAt: string;
56
+ updatedAt: string;
57
+ };
58
+
59
+ export type CreateContactInput = {
60
+ firstName?: string;
61
+ lastName?: string;
62
+ company?: string;
63
+ /** Email entries — addresses and/or `@domain` wildcards. Preferred input. */
64
+ emails?: string[];
65
+ /** @deprecated single-email back-compat. Folded into `emails` if `emails`
66
+ * is absent. New callers should pass `emails`. */
67
+ email?: string;
68
+ countryCode?: string;
69
+ cell?: string;
70
+ description?: string;
71
+ tags?: string[];
72
+ };
73
+
74
+ export type UpdateContactInput = CreateContactInput;
75
+
76
+ /** Strip everything but digits. Robust to user pasting "(760) 810-0774" etc. */
77
+ export function digitsOnly(s: string): string {
78
+ return (s ?? '').replace(/\D+/g, '');
79
+ }
80
+
81
+ /**
82
+ * Normalise a country-code input. Accepts "+27", "27", or "00 27" — returns
83
+ * "+27" if it looks like a plausible 1-4 digit country code, else "" (so the
84
+ * caller can refuse). Pure.
85
+ */
86
+ export function normalizeCountryCode(input: string): string {
87
+ const raw = (input ?? '').trim();
88
+ if (!raw) return '';
89
+ // "00 27" / "0027" → "27"
90
+ const digits = digitsOnly(raw).replace(/^00/, '');
91
+ // 1–4 digits and must NOT start with 0 — ITU-T E.164 codes are non-zero.
92
+ if (digits.length < 1 || digits.length > 4 || digits.startsWith('0')) return '';
93
+ return `+${digits}`;
94
+ }
95
+
96
+ /** E.164: country code + digits-only cell. Returns "" if either part is missing. */
97
+ export function toE164(countryCode: string, cell: string): string {
98
+ const cc = normalizeCountryCode(countryCode);
99
+ const local = digitsOnly(cell);
100
+ if (!cc || !local) return '';
101
+ return `${cc}${local}`;
102
+ }
103
+
104
+ /**
105
+ * Group a national-number string into chunks for display. Defensive default:
106
+ * group from the RIGHT into 4 + 3 + … so "760810774" → "76 081 0774". Country
107
+ * code prepended separately. Good enough for ZA/UK/AU/most ITU plans; not a
108
+ * libphonenumber replacement.
109
+ */
110
+ export function formatCell(countryCode: string, cell: string): string {
111
+ const cc = normalizeCountryCode(countryCode);
112
+ const digits = digitsOnly(cell);
113
+ if (!cc && !digits) return '';
114
+ if (!digits) return cc;
115
+ // Right-to-left groups: last 4, then 3, then 3, …
116
+ const groups: string[] = [];
117
+ let i = digits.length;
118
+ while (i > 0) {
119
+ const take = groups.length === 0 ? 4 : 3;
120
+ const start = Math.max(0, i - take);
121
+ groups.unshift(digits.slice(start, i));
122
+ i = start;
123
+ }
124
+ return cc ? `${cc} ${groups.join(' ')}` : groups.join(' ');
125
+ }
126
+
127
+ /** Lower-case + trim. We compare emails case-insensitively across the system. */
128
+ export function normalizeEmail(s: string): string {
129
+ return (s ?? '').trim().toLowerCase();
130
+ }
131
+
132
+ /** Cheap structural email check — `<local>@<host>.<tld>`. Deliberately permissive;
133
+ * the SMTP server is the real authority. */
134
+ export function isPlausibleEmail(s: string): boolean {
135
+ const e = normalizeEmail(s);
136
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
137
+ }
138
+
139
+ /** Domain shape for `@domain` wildcard entries (lower-cased, no leading `@`).
140
+ * Mirrors the validator the retired senders UI used. */
141
+ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
142
+
143
+ export type EmailEntryKind = 'address' | 'domain' | 'invalid';
144
+
145
+ /**
146
+ * Classify one contact email-list entry. A leading `@` marks a **domain
147
+ * wildcard** (`@example.com` = all mail from that domain); anything else must
148
+ * be a plausible full address. A bare domain without `@` (`example.com`) is
149
+ * rejected so the wildcard intent is always explicit and never confused with a
150
+ * malformed address.
151
+ */
152
+ export function classifyEntry(raw: string): EmailEntryKind {
153
+ const s = (raw ?? '').trim().toLowerCase();
154
+ if (!s) return 'invalid';
155
+ if (s.startsWith('@')) return DOMAIN_RE.test(s.slice(1)) ? 'domain' : 'invalid';
156
+ return isPlausibleEmail(s) ? 'address' : 'invalid';
157
+ }
158
+
159
+ /** Canonicalise an entry: lower-cased address, or `@domain` for a wildcard.
160
+ * Returns '' for invalid input (caller decides whether to reject). */
161
+ export function normalizeEmailEntry(raw: string): string {
162
+ const s = (raw ?? '').trim().toLowerCase();
163
+ switch (classifyEntry(s)) {
164
+ case 'address':
165
+ return s;
166
+ case 'domain':
167
+ return '@' + s.replace(/^@/, '');
168
+ default:
169
+ return '';
170
+ }
171
+ }
172
+
173
+ /** True when the entry is a usable address OR a `@domain` wildcard. */
174
+ export function isPlausibleEmailOrDomain(raw: string): boolean {
175
+ return classifyEntry(raw) !== 'invalid';
176
+ }
177
+
178
+ /** Normalise + de-dupe a list of entries, dropping anything invalid. Lenient —
179
+ * used for diffing/comparison where bad legacy values shouldn't throw. */
180
+ export function normalizeEmailEntries(entries: string[] | undefined): string[] {
181
+ const out: string[] = [];
182
+ const seen = new Set<string>();
183
+ for (const raw of entries ?? []) {
184
+ const norm = normalizeEmailEntry((raw ?? '').trim());
185
+ if (norm && !seen.has(norm)) {
186
+ seen.add(norm);
187
+ out.push(norm);
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * Split a contact's email entries into concrete `addresses` and bare `domains`
195
+ * (the `@` stripped). Entries are normalised + de-duped; invalid ones dropped.
196
+ *
197
+ * The **inbound** gate matches a From address against both sets. The
198
+ * **outbound** send allowlist uses `addresses` only — you can't send to a whole
199
+ * domain. This asymmetry is deliberate (a domain wildcard means "trust mail
200
+ * FROM here", not "I may send anywhere here").
201
+ */
202
+ export function partitionEmailEntries(entries: string[] | undefined): {
203
+ addresses: string[];
204
+ domains: string[];
205
+ } {
206
+ const addresses = new Set<string>();
207
+ const domains = new Set<string>();
208
+ for (const raw of entries ?? []) {
209
+ const norm = normalizeEmailEntry(raw);
210
+ if (!norm) continue;
211
+ if (norm.startsWith('@')) domains.add(norm.slice(1));
212
+ else addresses.add(norm);
213
+ }
214
+ return { addresses: [...addresses], domains: [...domains] };
215
+ }
216
+
217
+ /**
218
+ * A contact has a usable identity when at least one of name, last name, or
219
+ * company is set. Email/cell alone aren't enough — they're contact channels,
220
+ * not identities. Used by the save-side validation in `updateContact` and by
221
+ * the client form to pre-check before fetch. Pure + exported.
222
+ */
223
+ export function hasIdentity(input: {
224
+ firstName?: string;
225
+ lastName?: string;
226
+ company?: string;
227
+ }): boolean {
228
+ return Boolean(
229
+ (input.firstName ?? '').trim() || (input.lastName ?? '').trim() || (input.company ?? '').trim(),
230
+ );
231
+ }
232
+
233
+ /** Derive the title shown for a contact, in precedence order:
234
+ * 1. Person name ("First Last") — the most specific identifier when set.
235
+ * 2. Company / organisation name — for supplier/org contacts with no person.
236
+ * 3. Email address.
237
+ * 4. Formatted cell number.
238
+ * 5. "Untitled contact" — a brand-new empty draft (mirrors notes' "Untitled note").
239
+ * Name beats company so "Jane @ Modular" titles as "Jane Smith" with the
240
+ * company surfaced separately in the UI. Company-only contacts ("Modular"
241
+ * with no person) get the company as the title. */
242
+ export function deriveContactTitle(input: {
243
+ firstName?: string;
244
+ lastName?: string;
245
+ company?: string;
246
+ emails?: string[];
247
+ email?: string;
248
+ countryCode?: string;
249
+ cell?: string;
250
+ }): string {
251
+ const name = `${(input.firstName ?? '').trim()} ${(input.lastName ?? '').trim()}`.trim();
252
+ if (name) return name.slice(0, 200);
253
+ const company = (input.company ?? '').trim();
254
+ if (company) return company.slice(0, 200);
255
+ const email = normalizeEmail(input.emails?.[0] ?? input.email ?? '');
256
+ if (email) return email.slice(0, 200);
257
+ const fmt = formatCell(input.countryCode ?? '', input.cell ?? '');
258
+ if (fmt) return fmt.slice(0, 200);
259
+ return 'Untitled contact';
260
+ }