1
13import { z } from "zod";
14
15
26export const PROPERTY_TYPES = [
27 "TITLE",
28 "TEXT",
29 "RICH_TEXT",
30 "NUMBER",
31 "SELECT",
32 "MULTI_SELECT",
33 "STATUS",
34 "DATE",
35 "DATE_RANGE",
36 "PERSON",
37 "EMAIL",
38 "PHONE",
39 "URL",
40 "CHECKBOX",
41 "FILES",
42 "CREATED_TIME",
43 "CREATED_BY",
44 "LAST_EDITED_TIME",
45 "LAST_EDITED_BY",
46 "RELATION",
47 "ROLLUP",
48 "FORMULA",
49 "BUTTON",
50 "UNIQUE_ID",
51] as const;
52export type PropertyType = (typeof PROPERTY_TYPES)[number];
53
54
55export function isPropertyType(v: unknown): v is PropertyType {
56 return typeof v === "string" && (PROPERTY_TYPES as readonly string[]).includes(v);
57}
58
59
70export const GENERATABLE_PROPERTY_TYPES: readonly PropertyType[] = [
71 "TEXT",
72 "RICH_TEXT",
73 "NUMBER",
74 "SELECT",
75 "MULTI_SELECT",
76 "STATUS",
77 "DATE",
78 "DATE_RANGE",
79 "PERSON",
80 "EMAIL",
81 "PHONE",
82 "URL",
83 "CHECKBOX",
84 "FILES",
85 "CREATED_TIME",
86 "CREATED_BY",
87 "LAST_EDITED_TIME",
88 "LAST_EDITED_BY",
89 "UNIQUE_ID",
90];
91
92
93export const NON_GENERATABLE_PROPERTY_TYPES: Record<string, string> = {
94 TITLE: "reserved: a database seeds exactly one title property (use the `title` field)",
95 RELATION: "requires a target database (create a relation separately)",
96 ROLLUP: "requires a relation (create a relation separately)",
97 FORMULA: "generated by the dedicated formula tool, not database generation",
98 BUTTON: "requires view-action config",
99};
100
101export function isGeneratablePropertyType(v: PropertyType): boolean {
102 return (GENERATABLE_PROPERTY_TYPES as readonly string[]).includes(v);
103}
104
105
111const TYPE_ALIASES: Record<string, PropertyType> = {
112
113 title: "TITLE",
114 text: "TEXT",
115 rich_text: "RICH_TEXT",
116 richtext: "RICH_TEXT",
117 "rich text": "RICH_TEXT",
118 number: "NUMBER",
119 numeric: "NUMBER",
120 integer: "NUMBER",
121 float: "NUMBER",
122 select: "SELECT",
123 "single select": "SELECT",
124 "single-select": "SELECT",
125 dropdown: "SELECT",
126 multi_select: "MULTI_SELECT",
127 multiselect: "MULTI_SELECT",
128 "multi select": "MULTI_SELECT",
129 "multi-select": "MULTI_SELECT",
130 tags: "MULTI_SELECT",
131 status: "STATUS",
132 date: "DATE",
133 date_range: "DATE_RANGE",
134 daterange: "DATE_RANGE",
135 "date range": "DATE_RANGE",
136 person: "PERSON",
137 user: "PERSON",
138 assignee: "PERSON",
139 email: "EMAIL",
140 phone: "PHONE",
141 url: "URL",
142 link: "URL",
143 web_link: "URL",
144 "web link": "URL",
145 checkbox: "CHECKBOX",
146 check: "CHECKBOX",
147 boolean: "CHECKBOX",
148 bool: "CHECKBOX",
149 yes_no: "CHECKBOX",
150 files: "FILES",
151 file: "FILES",
152 attachment: "FILES",
153 attachments: "FILES",
154 created_time: "CREATED_TIME",
155 "created time": "CREATED_TIME",
156 created: "CREATED_TIME",
157 created_by: "CREATED_BY",
158 "created by": "CREATED_BY",
159 last_edited_time: "LAST_EDITED_TIME",
160 "last edited time": "LAST_EDITED_TIME",
161 last_edited_by: "LAST_EDITED_BY",
162 "last edited by": "LAST_EDITED_BY",
163 relation: "RELATION",
164 "many to many": "RELATION",
165 rollup: "ROLLUP",
166 formula: "FORMULA",
167 computed: "FORMULA",
168 button: "BUTTON",
169 action: "BUTTON",
170 unique_id: "UNIQUE_ID",
171 "unique id": "UNIQUE_ID",
172 id: "UNIQUE_ID",
173};
174
175
176export function normalizePropertyType(raw: unknown): PropertyType | null {
177 if (typeof raw !== "string") return null;
178 const key = raw.trim().toLowerCase().replace(/\s+/g, " ").replace(/[\s-]+/g, "_").replace(/_/g, "_");
179
180 const candidates = [key, key.replace(/_/g, " "), raw.trim().toLowerCase()];
181 for (const c of candidates) {
182 const hit = TYPE_ALIASES[c];
183 if (hit) return hit;
184 }
185 if (isPropertyType(raw)) return raw;
186 return null;
187}
188
189
190
191
192
193
194export const GeneratedPropertySchema = z.object({
195 name: z.string().min(1).max(100),
196
197 type: z.string().min(1),
198
199 options: z
200 .array(
201 z.object({
202 name: z.string().min(1).max(50),
203 color: z.string().max(20).optional(),
204 }),
205 )
206 .optional(),
207 required: z.boolean().optional(),
208});
209export type GeneratedProperty = z.infer<typeof GeneratedPropertySchema>;
210
211
213export const GeneratedSampleRecordSchema = z.object({
214 properties: z.record(z.string(), z.unknown()),
215});
216export type GeneratedSampleRecord = z.infer<typeof GeneratedSampleRecordSchema>;
217
218
222export const CreateDatabaseSchema = z.object({
223 title: z.string().min(1).max(200),
224 description: z.string().max(500).optional(),
225 properties: z.array(GeneratedPropertySchema).min(1).max(30),
226 sampleRecords: z.array(GeneratedSampleRecordSchema).max(10).optional(),
227});
228export type CreateDatabaseArgs = z.infer<typeof CreateDatabaseSchema>;
229
230
232export const InsertedRecordSchema = z.object({
233 properties: z.record(z.string(), z.unknown()),
234});
235export type InsertedRecord = z.infer<typeof InsertedRecordSchema>;
236
237
238export const InsertRecordsSchema = z.object({
239 records: z.array(InsertedRecordSchema).min(1).max(200),
240
241 ignoredColumns: z.array(z.string()).optional(),
242});
243export type InsertRecordsArgs = z.infer<typeof InsertRecordsSchema>;
244
245
247export const GenerateFormulaSchema = z.object({
248 expression: z.string().min(1).max(2000),
249 explanation: z.string().max(500).optional(),
250});
251export type GenerateFormulaArgs = z.infer<typeof GenerateFormulaSchema>;
252
253
265export { optionIdForName, normalizeSelectOptions } from "@factory/shared";
266
267
268export function lowerPropertyType(t: string): string {
269 return t.toLowerCase();
270}
271
272
273
274
275export const DB_GEN_MAX_PROPERTIES = 30;
276export const DB_GEN_MAX_SAMPLE_RECORDS = 10;
277export const DB_EXTRACT_MAX_RECORDS = 200;
278export const DB_EXTRACT_MAX_TEXT_CHARS = 50_000;
279export const DB_FORMULA_MAX_SAMPLE_ROWS = 5;
280
281
282
283
284
285
297export interface ParsedMarkdownTable {
298 isTable: boolean;
299 columns: string[];
300
301 rows: Array<Record<string, string>>;
302}
303
304function splitCells(line: string): string[] {
305
306 let s = line.trim();
307 if (s.startsWith("|")) s = s.slice(1);
308 if (s.endsWith("|")) s = s.slice(0, -1);
309
310 const SENTINEL = "\u0000PIPE\u0000";
311 s = s.replace(/\\\|/g, SENTINEL);
312 const cells = s.split("|").map((c) => c.replace(new RegExp(SENTINEL, "g"), "|").trim());
313 return cells;
314}
315
316
318function isSeparatorCell(c: string): boolean {
319 const t = c.trim();
320 return t === "" || /^:?-+:?$/.test(t);
321}
322
323
324function isSeparatorRow(line: string): boolean {
325 const s = line.trim();
326 if (s === "") return false;
327 const cells = splitCells(s);
328 if (cells.length === 0) return false;
329 const nonEmpty = cells.filter((c) => c.trim() !== "");
330 if (nonEmpty.length === 0) return false;
331 return cells.every(isSeparatorCell);
332}
333
334export function parseMarkdownTable(text: string): ParsedMarkdownTable {
335 const lines = text.split(/\r?\n/).filter((l) => l.trim() !== "");
336
337
338 const rowLines = lines.filter((l) => l.includes("|"));
339 const hasSeparator = rowLines.some((l) => isSeparatorRow(l));
340
341
342 if (rowLines.length < 2) return { isTable: false, columns: [], rows: [] };
343 const isTable = hasSeparator || rowLines.length >= 2;
344 if (!isTable) return { isTable: false, columns: [], rows: [] };
345
346 const header: string[] = [];
347 const dataLines: string[] = [];
348 for (const line of rowLines) {
349 if (isSeparatorRow(line)) continue;
350 if (header.length === 0 && dataLines.length === 0) {
351
352 header.push(...splitCells(line));
353 } else {
354 dataLines.push(line);
355 }
356 }
357 if (header.length === 0) return { isTable: false, columns: [], rows: [] };
358
359
360
361
362 const columns = header.map((h, i) => (h === "" ? `column_${i + 1}` : h));
363
364 const rows: Array<Record<string, string>> = dataLines.map((line) => {
365 const cells = splitCells(line);
366 const row: Record<string, string> = {};
367 columns.forEach((col, i) => {
368 row[col] = cells[i] ?? "";
369 });
370 return row;
371 });
372
373 return { isTable: true, columns, rows };
374}
375
376
383export function mapColumnsToProperties(
384 columns: string[],
385 properties: Array<{ id: string; name: string }>,
386): { mapped: Array<{ column: string; propertyId: string }>; ignored: string[] } {
387 const byName = new Map<string, string>();
388 for (const p of properties) byName.set(normalizeName(p.name), p.id);
389 const mapped: Array<{ column: string; propertyId: string }> = [];
390 const ignored: string[] = [];
391 for (const col of columns) {
392 const id = byName.get(normalizeName(col));
393 if (id) mapped.push({ column: col, propertyId: id });
394 else ignored.push(col);
395 }
396 return { mapped, ignored };
397}
398
399function normalizeName(s: string): string {
400 return s.trim().toLowerCase().replace(/\s+/g, " ");
401}
402