SPB Git
6.9 KB · 59 lines typescript
1/** Built-in database templates: applied onto an existing (empty) database. */2import { eq } from "drizzle-orm";3import { db, schema } from "@/db";4import { HttpError } from "@/lib/auth/guard";5import type { PropertyType, PropertyConfig, ViewType, ViewConfig, SelectOption } from "./types";6import { ensureDatabase, createProperty, createView } from "./service";7import { loadProps } from "./query";89type TProp = { name: string; type: PropertyType; config?: PropertyConfig; width?: number };10type TView = { name: string; type: ViewType; config?: (ids: Record<string, string>) => ViewConfig };11export interface Template { id: string; name: string; icon: string; description: string; title: string; properties: TProp[]; views: TView[] }12const opt = (names: string[], colors: SelectOption["color"][] = []): SelectOption[] => names.map((name, i) => ({ id: name.toLowerCase().replace(/[^a-z0-9]+/g, "_"), name, color: colors[i] ?? "gray" }));13const status = (): SelectOption[] => [{ id: "todo", name: "Not started", color: "gray", group: "todo" }, { id: "doing", name: "In progress", color: "blue", group: "in_progress" }, { id: "done", name: "Done", color: "green", group: "done" }];1415export const TEMPLATES: Template[] = [16  { id: "research-papers", name: "Research papers", icon: "📚", description: "Track papers you read, with status, domain, venue, DOI and AI-generated notes.", title: "Paper", properties: [17    { name: "Status", type: "status", config: { options: [{ id: "toread", name: "To read", color: "gray", group: "todo" }, { id: "reading", name: "Reading", color: "blue", group: "in_progress" }, { id: "read", name: "Read", color: "green", group: "done" }, { id: "cited", name: "Cited", color: "purple", group: "done" }] } },18    { name: "Domain", type: "multi_select", config: { options: opt(["Real estate", "Finance", "Machine learning", "LLM agents", "Econometrics", "Urban economics"], ["green", "blue", "purple", "pink", "orange", "teal"]) } },19    { name: "AI model", type: "select", config: { options: opt(["Claude", "GPT", "Gemini", "Llama", "Mistral", "Other"], ["purple", "green", "blue", "orange", "yellow", "gray"]) } },20    { name: "Year", type: "number", config: { format: "integer" }, width: 90 },21    { name: "Venue", type: "text" },22    { name: "DOI", type: "url" },23    { name: "Notes", type: "ai", config: { prompt: "Write a 3-sentence summary of what this paper is likely about and why it matters, based on its title, venue and domain. If the information is insufficient, say what is missing." } },24    { name: "Added", type: "created_time", width: 130 },25  ], views: [{ name: "All papers", type: "table" }, { name: "By status", type: "board", config: (ids) => ({ groupBy: ids.Status }) }, { name: "Gallery", type: "gallery" }] },26  { id: "tasks", name: "Tasks", icon: "✅", description: "Simple task tracker with priority, due date, project and a board by status.", title: "Task", properties: [27    { name: "Status", type: "status", config: { options: status() } },28    { name: "Priority", type: "select", config: { options: opt(["Low", "Medium", "High", "Urgent"], ["gray", "blue", "orange", "red"]) }, width: 110 },29    { name: "Due", type: "date", width: 130 },30    { name: "Project", type: "select", config: { options: [] } },31    { name: "Done", type: "checkbox", width: 70 },32    { name: "Days left", type: "formula", config: { expression: 'if(empty(prop("Due")), "", dateDiff(prop("Due"), today(), "days"))' }, width: 100 },33  ], views: [{ name: "All tasks", type: "table", config: (ids) => ({ sorts: [{ property: ids.Due, dir: "asc" }] }) }, { name: "Board", type: "board", config: (ids) => ({ groupBy: ids.Status }) }, { name: "Calendar", type: "calendar", config: (ids) => ({ dateProperty: ids.Due }) }, { name: "Timeline", type: "timeline", config: (ids) => ({ startProperty: ids.Due, endProperty: ids.Due }) }] },34  { id: "reading-list", name: "Reading list", icon: "📖", description: "Books, articles and links to read, with type, rating and a short AI pitch.", title: "Title", properties: [35    { name: "Type", type: "select", config: { options: opt(["Book", "Article", "Paper", "Video", "Podcast", "Link"], ["blue", "green", "purple", "orange", "pink", "gray"]) }, width: 110 },36    { name: "Status", type: "status", config: { options: [{ id: "want", name: "Want to read", color: "gray", group: "todo" }, { id: "reading", name: "Reading", color: "blue", group: "in_progress" }, { id: "finished", name: "Finished", color: "green", group: "done" }] } },37    { name: "Author", type: "text" },38    { name: "Link", type: "url" },39    { name: "Rating", type: "number", config: { format: "integer" }, width: 90 },40    { name: "Tags", type: "multi_select", config: { options: [] } },41    { name: "Finished on", type: "date", width: 130 },42    { name: "Pitch", type: "ai", config: { prompt: "In one sentence, explain why this item could be worth reading for a professor of real-estate finance who does AI research." } },43  ], views: [{ name: "List", type: "list" }, { name: "Table", type: "table" }, { name: "Gallery", type: "gallery" }, { name: "By status", type: "board", config: (ids) => ({ groupBy: ids.Status }) }] },44];45export async function applyTemplate(wsId: string, dbId: string, templateId: string) {46  const t = TEMPLATES.find((x) => x.id === templateId); if (!t) throw new HttpError(400, "Unknown template");47  const d = await ensureDatabase(wsId, dbId); const existing = await loadProps(d.id); const ids: Record<string, string> = {};48  const tp = existing.find((p) => p.type === "title"); if (tp) { await db.update(schema.databaseProperties).set({ name: t.title }).where(eq(schema.databaseProperties.id, tp.id)); ids[t.title] = tp.id; }49  for (const p of t.properties) { const ex = existing.find((x) => x.name.toLowerCase() === p.name.toLowerCase()); if (ex) { ids[p.name] = ex.id; continue; } const np = await createProperty(wsId, d.id, { name: p.name, type: p.type, config: p.config, width: p.width ?? null }); ids[p.name] = np.id; }50  const views = await db.select().from(schema.databaseViews).where(eq(schema.databaseViews.databaseId, d.id));51  const first = t.views[0]; const defaultView = views.find((v) => v.isDefault) ?? views[0];52  if (defaultView && views.length === 1 && defaultView.type === first.type) await db.update(schema.databaseViews).set({ name: first.name, config: first.config?.(ids) ?? {} }).where(eq(schema.databaseViews.id, defaultView.id));53  else await createView(wsId, d.id, { name: first.name, type: first.type, config: first.config?.(ids) });54  for (const v of t.views.slice(1)) await createView(wsId, d.id, { name: v.name, type: v.type, config: v.config?.(ids) });55  if (!d.icon) { await db.update(schema.pages).set({ icon: t.icon }).where(eq(schema.pages.id, d.pageId)); await db.update(schema.databases).set({ icon: t.icon }).where(eq(schema.databases.id, d.id)); }56  return { applied: t.id, properties: Object.keys(ids).length };57}58export const templateSummaries = () => TEMPLATES.map((t) => ({ id: t.id, name: t.name, icon: t.icon, description: t.description, properties: [t.title, ...t.properties.map((p) => p.name)] }));59
Files · 2c37cb5a3e2af18e5ba361996d453cab97da82c2
Menu

Theme