From d4f1f3091650b84cc4cf6e257e1ba0bca4e5a87c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:57:47 -0400 Subject: [PATCH 1/7] feat(content): add configurable database CSV exports --- .../actions/export-document.db.test.ts | 154 +++++++++- templates/content/actions/export-document.ts | 258 ++++++++++++++++- .../components/editor/DocumentDatabase.tsx | 9 +- .../app/components/editor/DocumentEditor.tsx | 10 +- .../app/components/editor/DocumentToolbar.tsx | 23 ++ .../database/DatabaseExportDialog.test.ts | 83 ++++++ .../editor/database/DatabaseExportDialog.tsx | 267 ++++++++++++++++++ .../editor/database/DatabaseView.tsx | 37 +++ templates/content/app/i18n-data.ts | 11 + ...can-be-configured-and-downloaded-as-csv.md | 6 + .../content/shared/database-csv-export.ts | 59 ++++ .../content/shared/document-export.spec.ts | 83 ++++++ 12 files changed, 987 insertions(+), 13 deletions(-) create mode 100644 templates/content/app/components/editor/database/DatabaseExportDialog.test.ts create mode 100644 templates/content/app/components/editor/database/DatabaseExportDialog.tsx create mode 100644 templates/content/changelog/2026-08-18-database-exports-can-be-configured-and-downloaded-as-csv.md create mode 100644 templates/content/shared/database-csv-export.ts diff --git a/templates/content/actions/export-document.db.test.ts b/templates/content/actions/export-document.db.test.ts index 1d7b0940e5..2812fd06f8 100644 --- a/templates/content/actions/export-document.db.test.ts +++ b/templates/content/actions/export-document.db.test.ts @@ -123,6 +123,51 @@ async function shareDocumentWithOwner(documentId: string, id: string) { }); } +async function addProperty(args: { + id: string; + databaseId: string; + name: string; + type: string; + position: number; + optionsJson?: string; +}) { + const now = new Date().toISOString(); + await getDb() + .insert(schema.documentPropertyDefinitions) + .values({ + id: args.id, + ownerEmail: OWNER, + databaseId: args.databaseId, + name: args.name, + type: args.type, + visibility: "always_show", + optionsJson: args.optionsJson ?? "{}", + position: args.position, + createdAt: now, + updatedAt: now, + }); +} + +async function setPropertyValue(args: { + id: string; + documentId: string; + propertyId: string; + value: unknown; +}) { + const now = new Date().toISOString(); + await getDb() + .insert(schema.documentPropertyValues) + .values({ + id: args.id, + ownerEmail: OWNER, + documentId: args.documentId, + propertyId: args.propertyId, + valueJson: JSON.stringify(args.value), + createdAt: now, + updatedAt: now, + }); +} + describe("export-document database collections", () => { it.each(["table", "list"] as const)( "exports immediate authorized members from a %s view in membership order for every format", @@ -191,17 +236,25 @@ describe("export-document database collections", () => { position: 0, }); - const [markdown, html, pdf] = await runWithRequestContext( + const [markdown, html, pdf, csv] = await runWithRequestContext( { userEmail: OWNER }, () => - Promise.all( - (["markdown", "html", "pdf"] as const).map((format) => + Promise.all([ + ...(["markdown", "html", "pdf"] as const).map((format) => exportDocumentAction.run({ id: databaseDocumentId, format, }), ), - ), + exportDocumentAction.run({ + id: databaseDocumentId, + format: "csv", + collection: { + scope: { kind: "all_members" }, + propertyIds: [], + }, + }), + ]), ); expect(markdown.content).toBe( @@ -221,6 +274,32 @@ describe("export-document database collections", () => { result.content.indexOf("

FAQ

"), ); } + expect(csv.content).toBe( + "Title\r\nAnnouncement\r\nFAQ\r\nShared record\r\n", + ); + + const currentViewCsv = await runWithRequestContext( + { userEmail: OWNER }, + () => + exportDocumentAction.run({ + id: databaseDocumentId, + format: "csv", + collection: { + scope: { + kind: "current_view", + viewId: "primary", + query: { + search: "FAQ", + filters: [], + sorts: [], + filterMode: "and", + }, + }, + propertyIds: [], + }, + }), + ); + expect(currentViewCsv.content).toBe("Title\r\nFAQ\r\n"); }, ); @@ -330,6 +409,73 @@ describe("export-document database collections", () => { ); }); + it("exports selected scalar CSV columns without waiting for unselected Blocks", async () => { + const databaseId = "csv-scalar-database"; + const databaseDocumentId = "csv-scalar-database-document"; + await createDatabase({ + id: databaseId, + documentId: databaseDocumentId, + title: "CSV Scalars", + }); + await createDocument({ id: "csv-scalar-row", title: "=formula" }); + await addDatabaseItem({ + id: "csv-scalar-item", + databaseId, + documentId: "csv-scalar-row", + position: 0, + bodyHydrationStatus: "pending", + }); + await addProperty({ + id: "csv-status", + databaseId, + name: "Status", + type: "status", + position: 0, + optionsJson: JSON.stringify({ + options: [{ id: "ready", name: "Ready" }], + }), + }); + await addProperty({ + id: "csv-blocks", + databaseId, + name: "Content", + type: "blocks", + position: 1, + optionsJson: JSON.stringify({ blocks: { primary: true } }), + }); + await setPropertyValue({ + id: "csv-status-value", + documentId: "csv-scalar-row", + propertyId: "csv-status", + value: "ready", + }); + + const result = await runWithRequestContext({ userEmail: OWNER }, () => + exportDocumentAction.run({ + id: databaseDocumentId, + format: "csv", + collection: { + scope: { kind: "all_members" }, + propertyIds: ["csv-status"], + }, + }), + ); + + expect(result.content).toBe("Title,Status\r\n'=formula,Ready\r\n"); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + exportDocumentAction.run({ + id: databaseDocumentId, + format: "csv", + collection: { + scope: { kind: "all_members" }, + propertyIds: ["csv-blocks"], + }, + }), + ), + ).rejects.toThrow('Database item "csv-scalar-row" is not ready for export'); + }); + it("keeps ordinary page exports unchanged", async () => { await createDocument({ id: "ordinary-page", diff --git a/templates/content/actions/export-document.ts b/templates/content/actions/export-document.ts index 7ea012c3bb..e081c7f2b4 100644 --- a/templates/content/actions/export-document.ts +++ b/templates/content/actions/export-document.ts @@ -1,11 +1,18 @@ import { defineAction } from "@agent-native/core"; import { buildDeepLink } from "@agent-native/core/server"; -import { resolveAccess } from "@agent-native/core/sharing"; -import { asc, eq } from "drizzle-orm"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { accessFilter, resolveAccess } from "@agent-native/core/sharing"; +import { and, asc, eq, isNull, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import type { + ContentDatabaseItem, + ContentDatabaseTableQuery, +} from "../shared/api.js"; import { blocksContentHash } from "../shared/blocks-field-identity.js"; +import { renderDatabaseCsv } from "../shared/database-csv-export.js"; +import { applyContentDatabaseTableQuery } from "../shared/database-query.js"; import { buildDocumentExport, collectionItemsMarkdown, @@ -16,11 +23,221 @@ import { isPrimaryBlocksField, } from "../shared/properties.js"; import { resolveContentDocumentAccess } from "./_content-document-access.js"; -import { getDatabaseByDocumentId } from "./_database-utils.js"; -import { listPropertiesForAllDocumentDatabases } from "./_property-utils.js"; +import { listContentOrganizationMemberships } from "./_content-space-access.js"; +import { + CONTENT_DATABASE_MAX_READ_LIMIT, + getDatabaseByDocumentId, +} from "./_database-utils.js"; +import { + listPropertiesForAllDocumentDatabases, + listPropertiesForDatabase, + listPropertiesForDatabaseDocuments, + parseDatabaseViewConfig, +} from "./_property-utils.js"; const COLLECTION_EXPORT_ACCESS_CONCURRENCY = 8; +const collectionSchema = z.object({ + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("all_members") }), + z.object({ + kind: z.literal("current_view"), + viewId: z.string().min(1), + query: z.object({ + search: z.string().max(500), + filters: z + .array( + z.object({ + key: z.string(), + label: z.string(), + operator: z.enum([ + "contains", + "equals", + "does_not_equal", + "greater_than", + "less_than", + "before", + "after", + "between", + "is_checked", + "is_unchecked", + "is_empty", + "is_not_empty", + ]), + value: z.string(), + filterGroupId: z.string().optional(), + parentFilterGroupId: z.string().optional(), + }), + ) + .max(50), + sorts: z + .array( + z.object({ + key: z.string(), + label: z.string(), + direction: z.enum(["asc", "desc"]), + }), + ) + .max(20), + filterMode: z.enum(["and", "or"]), + }), + }), + ]), + propertyIds: z.array(z.string().min(1)).max(200), +}); + +type CollectionExport = z.infer; + +function assertValidQuery( + query: ContentDatabaseTableQuery, + propertyIds: ReadonlySet, +) { + for (const key of [...query.filters, ...query.sorts].map(({ key }) => key)) { + if (key !== "name" && !propertyIds.has(key)) { + throw new Error(`Unknown database property "${key}" in export query`); + } + } +} + +async function databaseCsvContent( + documentId: string, + collection: CollectionExport, +) { + const database = await getDatabaseByDocumentId(documentId); + if (!database) throw new Error("CSV export requires a database document"); + + const properties = await listPropertiesForDatabase(database.id); + const propertyById = new Map( + properties.map((property) => [property.definition.id, property]), + ); + if (new Set(collection.propertyIds).size !== collection.propertyIds.length) { + throw new Error("CSV export property IDs must be unique"); + } + const selectedProperties = collection.propertyIds.map((propertyId) => { + const property = propertyById.get(propertyId); + if (!property) throw new Error(`Unknown database property "${propertyId}"`); + return property; + }); + + const query = + collection.scope.kind === "current_view" ? collection.scope.query : null; + const scope = collection.scope; + if (scope.kind === "current_view") { + const view = parseDatabaseViewConfig(database.viewConfigJson).views.find( + (candidate) => candidate.id === scope.viewId, + ); + if (!view) throw new Error(`Database view "${scope.viewId}" not found`); + assertValidQuery(query!, new Set(propertyById.keys())); + } + const blocksAreNeeded = + selectedProperties.some((property) => + isBlocksPropertyType(property.definition.type), + ) || + (!!query && + (query.search.trim().length > 0 || + [...query.filters, ...query.sorts].some((constraint) => { + const property = propertyById.get(constraint.key); + return !!property && isBlocksPropertyType(property.definition.type); + }))); + + const userEmail = getRequestUserEmail(); + const memberships = userEmail + ? await listContentOrganizationMemberships(userEmail) + : []; + const accessClauses = [accessFilter(schema.documents, schema.documentShares)]; + for (const membership of memberships) { + accessClauses.push( + accessFilter(schema.documents, schema.documentShares, { + userEmail: userEmail!, + orgId: membership.orgId, + }), + ); + } + const rows = await getDb() + .select({ + item: schema.contentDatabaseItems, + document: schema.documents, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, database.id), + isNull(schema.documents.trashedAt), + or(...accessClauses), + ), + ) + .orderBy( + asc(schema.contentDatabaseItems.position), + asc(schema.contentDatabaseItems.createdAt), + asc(schema.contentDatabaseItems.id), + ) + .limit(CONTENT_DATABASE_MAX_READ_LIMIT + 1); + if (rows.length > CONTENT_DATABASE_MAX_READ_LIMIT) { + throw new Error( + `CSV export supports up to ${CONTENT_DATABASE_MAX_READ_LIMIT} accessible rows.`, + ); + } + if (blocksAreNeeded) { + for (const { item } of rows) { + if ( + item.bodyHydrationStatus !== "hydrated" && + item.bodyHydrationStatus !== "unavailable" + ) { + throw new Error( + `Database item "${item.documentId}" is not ready for export`, + ); + } + } + } + const documents = rows.map((row) => row.document); + const propertiesByDocumentId = await listPropertiesForDatabaseDocuments( + database.id, + documents, + ); + const queryItems: ContentDatabaseItem[] = rows.map((row) => ({ + id: row.item.id, + databaseId: row.item.databaseId, + document: { + id: row.document.id, + parentId: row.document.parentId, + title: row.document.title, + content: row.document.content, + description: row.document.description ?? undefined, + icon: row.document.icon, + position: row.document.position, + isFavorite: row.document.isFavorite === 1, + hideFromSearch: row.document.hideFromSearch === 1, + createdAt: row.document.createdAt, + updatedAt: row.document.updatedAt, + }, + position: row.item.position, + properties: propertiesByDocumentId.get(row.document.id) ?? [], + })); + const selectedRows = query + ? applyContentDatabaseTableQuery(queryItems, properties, query) + : queryItems; + return renderDatabaseCsv( + selectedProperties.map((property) => ({ + id: property.definition.id, + name: property.definition.name, + property, + })), + selectedRows.map((row) => ({ + title: row.document.title, + values: new Map( + row.properties.map((property) => [ + property.definition.id, + property.value, + ]), + ), + })), + ); +} + async function databaseExportContent(documentId: string) { const database = await getDatabaseByDocumentId(documentId); if (!database) return null; @@ -82,9 +299,12 @@ export default defineAction({ schema: z.object({ id: z.string().describe("Document ID (required)"), format: z - .enum(["pdf", "markdown", "html"]) + .enum(["pdf", "markdown", "html", "csv"]) .default("pdf") - .describe("Export format: pdf, markdown, or html."), + .describe("Export format: pdf, markdown, html, or csv."), + collection: collectionSchema + .optional() + .describe("Database CSV export scope and selected property IDs."), title: z .string() .max(500) @@ -98,11 +318,35 @@ export default defineAction({ }), readOnly: true, publicAgent: { expose: true, readOnly: true, requiresAuth: true }, - run: async ({ id, format, title, content }) => { + run: async ({ id, format, title, content, collection }) => { const access = await resolveAccess("document", id); if (!access) throw new Error(`Document "${id}" not found`); const doc = access.resource; + if (format === "csv") { + if (!collection) + throw new Error("CSV export requires collection options"); + const content = await databaseCsvContent(doc.id, collection); + return { + id: doc.id, + title: doc.title || "Untitled", + format, + filename: `${ + (doc.title || "untitled") + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() || "untitled" + }.csv`, + mimeType: "text/csv;charset=utf-8", + content, + print: false, + deepLink: buildDeepLink({ + app: "content", + view: "editor", + params: { documentId: doc.id }, + }), + }; + } const properties = await listPropertiesForAllDocumentDatabases(doc); const blocksFields = properties .filter((property) => isBlocksPropertyType(property.definition.type)) diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index 59b3b58736..f8cab5c04f 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -1,5 +1,6 @@ import type { Document } from "@shared/api"; +import type { DatabaseExportContext } from "./database/DatabaseExportDialog"; import { DatabaseView } from "./database/DatabaseView"; export * from "./database/DatabaseView"; @@ -7,9 +8,14 @@ export * from "./database/DatabaseView"; interface DocumentDatabaseProps { document: Document; canEdit: boolean; + onExportContextChange?: (context: DatabaseExportContext | null) => void; } -export function DocumentDatabase({ document, canEdit }: DocumentDatabaseProps) { +export function DocumentDatabase({ + document, + canEdit, + onExportContextChange, +}: DocumentDatabaseProps) { const databaseId = document.database?.id; if (!databaseId) return null; @@ -18,6 +24,7 @@ export function DocumentDatabase({ document, canEdit }: DocumentDatabaseProps) { databaseId={databaseId} databaseDocumentId={document.id} canEdit={canEdit} + onExportContextChange={onExportContextChange} /> ); } diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 41b620a67c..052e72e86b 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -94,6 +94,7 @@ import { import { BuilderBodySyncingNotice } from "./BuilderBodySyncingNotice"; import type { CommentTextAnchor } from "./comment-anchors"; import { CommentsSidebar } from "./CommentsSidebar"; +import type { DatabaseExportContext } from "./database/DatabaseExportDialog"; import { DocumentBlockFields } from "./DocumentBlockFields"; import { DocumentDatabase } from "./DocumentDatabase"; import { DocumentEditorSkeleton } from "./DocumentEditorSkeleton"; @@ -601,6 +602,8 @@ function DocumentEditorBody({ const pushDocumentToNotion = usePushDocumentToNotion(documentId); const [localTitle, setLocalTitle] = useState(""); const [localContent, setLocalContent] = useState(""); + const [databaseExportContext, setDatabaseExportContext] = + useState(null); const [newDocumentTypeChosen, setNewDocumentTypeChosen] = useState(false); const [localContentUpdatedAt, setLocalContentUpdatedAt] = useState< string | null @@ -1759,6 +1762,7 @@ function DocumentEditorBody({ documentId={documentId} documentTitle={exportTitle} documentContent={exportContent} + databaseExportContext={databaseExportContext} breadcrumbItems={toolbarBreadcrumbItems.map((item) => item.id === documentId ? { ...item, title: exportTitle } : item, )} @@ -1896,7 +1900,11 @@ function DocumentEditorBody({ {document.database ? (
- +
) : null} diff --git a/templates/content/app/components/editor/DocumentToolbar.tsx b/templates/content/app/components/editor/DocumentToolbar.tsx index ea7385d883..a17060db1b 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -97,6 +97,10 @@ import { } from "@/lib/local-content-source-files"; import { cn } from "@/lib/utils"; +import { + DatabaseExportDialog, + type DatabaseExportContext, +} from "./database/DatabaseExportDialog"; import { VersionHistoryPanel } from "./VersionHistoryPanel"; type ExportFormat = "pdf" | "markdown" | "html"; @@ -472,6 +476,7 @@ interface DocumentToolbarProps { onUtilityPanelChange: (panel: "info" | "comments" | null) => void; showCommentsControl?: boolean; onOpenBreadcrumbItem?: (id: string) => void; + databaseExportContext?: DatabaseExportContext | null; } export function DocumentToolbar({ @@ -494,6 +499,7 @@ export function DocumentToolbar({ onUtilityPanelChange, showCommentsControl = true, onOpenBreadcrumbItem, + databaseExportContext, }: DocumentToolbarProps) { const t = useT(); const navigate = useNavigate(); @@ -530,6 +536,7 @@ export function DocumentToolbar({ boolean | null >(null); const [historyOpen, setHistoryOpen] = useState(false); + const [databaseExportOpen, setDatabaseExportOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); @@ -1054,6 +1061,15 @@ export function DocumentToolbar({ {t("editor.toolbar.export")} + {databaseExportContext ? ( + setDatabaseExportOpen(true)} + > + + CSV + + ) : null} void handleExport("pdf")} @@ -1478,6 +1494,13 @@ export function DocumentToolbar({ + ); } diff --git a/templates/content/app/components/editor/database/DatabaseExportDialog.test.ts b/templates/content/app/components/editor/database/DatabaseExportDialog.test.ts new file mode 100644 index 0000000000..fd63fbd1f6 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseExportDialog.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + databaseCsvRequest, + defaultDatabaseCsvPropertyIds, + type DatabaseExportContext, +} from "./DatabaseExportDialog"; + +const context: DatabaseExportContext = { + viewId: "view-active", + viewName: "Active view", + query: { + search: "roadmap", + filters: [ + { + key: "status", + label: "Status", + operator: "equals", + value: "published", + }, + ], + sorts: [{ key: "date", label: "Date", direction: "desc" }], + filterMode: "and", + }, + properties: [ + { id: "visible-text", name: "Visible text", type: "text", visible: true }, + { + id: "hidden-number", + name: "Hidden number", + type: "number", + visible: false, + }, + { id: "body", name: "Body", type: "blocks", visible: true }, + ], +}; + +describe("DatabaseExportDialog", () => { + it("defaults to visible scalar columns, excluding blocks", () => { + expect(defaultDatabaseCsvPropertyIds(context.properties)).toEqual([ + "visible-text", + ]); + }); + + it("sends the exact all-members CSV payload", () => { + expect( + databaseCsvRequest({ + id: "database-page", + context, + scope: "all_members", + propertyIds: ["visible-text", "body"], + }), + ).toEqual({ + id: "database-page", + format: "csv", + collection: { + scope: { kind: "all_members" }, + propertyIds: ["visible-text", "body"], + }, + }); + }); + + it("retains the complete active-view query for a current-view export", () => { + expect( + databaseCsvRequest({ + id: "database-page", + context, + scope: "current_view", + propertyIds: ["visible-text"], + }), + ).toEqual({ + id: "database-page", + format: "csv", + collection: { + scope: { + kind: "current_view", + viewId: "view-active", + query: context.query, + }, + propertyIds: ["visible-text"], + }, + }); + }); +}); diff --git a/templates/content/app/components/editor/database/DatabaseExportDialog.tsx b/templates/content/app/components/editor/database/DatabaseExportDialog.tsx new file mode 100644 index 0000000000..0a64044fd6 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseExportDialog.tsx @@ -0,0 +1,267 @@ +import { useActionMutation } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import type { + ContentDatabaseFilter, + ContentDatabaseFilterMode, + ContentDatabaseSort, +} from "@shared/api"; +import { IconDownload, IconLoader2 } from "@tabler/icons-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export type DatabaseExportScopeKind = "all_members" | "current_view"; + +export interface DatabaseExportProperty { + id: string; + name: string; + type: string; + visible: boolean; +} + +export interface DatabaseExportContext { + viewId: string; + viewName: string; + query: { + search: string; + filters: ContentDatabaseFilter[]; + sorts: ContentDatabaseSort[]; + filterMode: ContentDatabaseFilterMode; + }; + properties: DatabaseExportProperty[]; +} + +interface DatabaseCsvResult { + filename: string; + mimeType: string; + content: string; +} + +export function defaultDatabaseCsvPropertyIds( + properties: DatabaseExportProperty[], +) { + return properties + .filter((property) => property.visible && property.type !== "blocks") + .map((property) => property.id); +} + +export function databaseCsvRequest(args: { + id: string; + context: DatabaseExportContext; + scope: DatabaseExportScopeKind; + propertyIds: string[]; +}) { + return { + id: args.id, + format: "csv" as const, + collection: { + scope: + args.scope === "current_view" + ? { + kind: "current_view" as const, + viewId: args.context.viewId, + query: args.context.query, + } + : { kind: "all_members" as const }, + propertyIds: args.propertyIds, + }, + }; +} + +function downloadCsv(result: DatabaseCsvResult) { + const blob = new Blob([result.content], { type: result.mimeType }); + const url = URL.createObjectURL(blob); + const link = window.document.createElement("a"); + link.href = url; + link.download = result.filename; + window.document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +export function DatabaseExportDialog({ + documentId, + context, + defaultScope, + open, + onOpenChange, +}: { + documentId: string; + context: DatabaseExportContext | null; + defaultScope: DatabaseExportScopeKind; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const t = useT(); + const exportDocument = useActionMutation("export-document"); + const [scope, setScope] = useState(defaultScope); + const [propertyIds, setPropertyIds] = useState([]); + const defaultPropertyIds = useMemo( + () => defaultDatabaseCsvPropertyIds(context?.properties ?? []), + [context], + ); + + useEffect(() => { + if (!open) return; + setScope(defaultScope); + setPropertyIds(defaultPropertyIds); + }, [defaultPropertyIds, defaultScope, open]); + + const toggleProperty = (id: string, checked: boolean) => { + setPropertyIds((current) => + checked + ? [...new Set([...current, id])] + : current.filter((x) => x !== id), + ); + }; + + const handleExport = async () => { + if (!context || exportDocument.isPending) return; + try { + const result = (await exportDocument.mutateAsync( + databaseCsvRequest({ id: documentId, context, scope, propertyIds }), + )) as DatabaseCsvResult; + downloadCsv(result); + toast.success(t("editor.toolbar.exportedCsv")); + onOpenChange(false); + } catch (error) { + toast.error(t("editor.toolbar.exportFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }; + + if (!context) return null; + + return ( + + + + {t("editor.toolbar.exportDatabase")} + +
+
+ + +
+
+
+ {t("editor.toolbar.exportColumns")} +
+
+ + {context.properties.map((property) => { + const checked = propertyIds.includes(property.id); + const isBlocks = property.type === "blocks"; + return ( + + ); + })} +
+
+
+ + + + +
+
+ ); +} diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 9ea6b748e0..47fc6fcb78 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -276,6 +276,7 @@ import { releasePreviewDocumentSaveController, } from "../previewDocumentSaveRegistry"; import { VisualEditor } from "../VisualEditor"; +import type { DatabaseExportContext } from "./DatabaseExportDialog"; import { DatabaseFormView } from "./FormView"; import { DatabaseGalleryView } from "./GalleryView"; import { DatabaseListView } from "./ListView"; @@ -294,6 +295,7 @@ export interface DatabaseViewProps { renderMode?: "page" | "inline"; canEdit?: boolean; isActive?: boolean; + onExportContextChange?: (context: DatabaseExportContext | null) => void; } const CONTENT_DATABASE_PAGE_SIZE = 100; @@ -741,6 +743,7 @@ export function DatabaseView({ renderMode = "page", canEdit = true, isActive, + onExportContextChange, }: DatabaseViewProps) { const { data: document } = useDocument(databaseDocumentId); @@ -756,6 +759,7 @@ export function DatabaseView({ renderMode={renderMode} canEdit={effectiveCanEdit} isActive={isActive ?? renderMode === "page"} + onExportContextChange={onExportContextChange} /> ); } @@ -768,6 +772,7 @@ function DatabaseTable({ renderMode, canEdit, isActive, + onExportContextChange, }: { document: Document; databaseId: string; @@ -776,6 +781,7 @@ function DatabaseTable({ renderMode: "page" | "inline"; canEdit: boolean; isActive: boolean; + onExportContextChange?: (context: DatabaseExportContext | null) => void; }) { const t = useT(); const navigate = useNavigate(); @@ -1062,6 +1068,37 @@ function DatabaseTable({ ), [orderedProperties, items, activeView], ); + const exportContext = useMemo( + () => ({ + viewId: activeView.id, + viewName: activeView.name, + query: { search: searchQuery, filters, sorts, filterMode }, + properties: orderedProperties.map((property) => ({ + id: property.definition.id, + name: property.definition.name, + type: property.definition.type, + visible: isDatabasePropertyVisibleInView(property, items, activeView), + })), + }), + [ + activeView, + filterMode, + filters, + items, + orderedProperties, + searchQuery, + sorts, + ], + ); + useEffect(() => { + // Inline blocks share this component but cannot replace the page snapshot. + if (renderMode === "page") onExportContextChange?.(exportContext); + }, [exportContext, onExportContextChange, renderMode]); + useEffect(() => { + return () => { + if (renderMode === "page") onExportContextChange?.(null); + }; + }, [onExportContextChange, renderMode]); const visibleItems = useMemo( () => applyDatabaseView( diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 7f53bc6f32..68d3a729a7 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2830,6 +2830,17 @@ const editorToolbarMessages = { "Clipboard access is not available in this browser.", edited: "Edited", export: "Export", + exportCsv: "Export CSV", + exportDatabase: "Export database", + exportScope: "Export scope", + currentView: "Current view", + allDatabaseMembers: "All database members", + allDatabaseMembersDetail: "Every page in this database", + exportColumns: "Columns", + titleColumn: "Title", + blocksColumn: "Blocks", + exporting: "Exporting...", + exportedCsv: "Exported CSV", exportFailed: "Export failed", exportedHtml: "Exported HTML", exportedMarkdown: "Exported Markdown", diff --git a/templates/content/changelog/2026-08-18-database-exports-can-be-configured-and-downloaded-as-csv.md b/templates/content/changelog/2026-08-18-database-exports-can-be-configured-and-downloaded-as-csv.md new file mode 100644 index 0000000000..3834d450ca --- /dev/null +++ b/templates/content/changelog/2026-08-18-database-exports-can-be-configured-and-downloaded-as-csv.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-08-18 +--- + +Database exports can be configured and downloaded as CSV diff --git a/templates/content/shared/database-csv-export.ts b/templates/content/shared/database-csv-export.ts new file mode 100644 index 0000000000..2ff421dad2 --- /dev/null +++ b/templates/content/shared/database-csv-export.ts @@ -0,0 +1,59 @@ +import type { DocumentProperty } from "./api.js"; +import { formulaValueText, type DocumentPropertyValue } from "./properties.js"; + +export interface DatabaseCsvColumn { + id: string; + name: string; + property: { + definition: Pick; + }; +} + +function propertyValueText( + property: DatabaseCsvColumn["property"], + value: DocumentPropertyValue | undefined, +): string { + if (value == null) return ""; + const optionName = (entry: string) => + property.definition.options.options?.find((option) => option.id === entry) + ?.name ?? entry; + if (Array.isArray(value)) return value.map(optionName).join(", "); + if ( + property.definition.type === "select" || + property.definition.type === "status" + ) { + return optionName(String(value)); + } + if (property.definition.type === "checkbox") { + return value ? "TRUE" : "FALSE"; + } + return formulaValueText(value); +} + +export interface DatabaseCsvRow { + title: string | null | undefined; + values: ReadonlyMap; +} + +function csvCell(value: string): string { + const safe = /^[\t ]*[=+\-@]/.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +/** Render an already-authorized, ordered database projection as RFC 4180 CSV. */ +export function renderDatabaseCsv( + columns: readonly DatabaseCsvColumn[], + rows: readonly DatabaseCsvRow[], +): string { + const header = ["Title", ...columns.map((column) => column.name)]; + const data = rows.map((row) => [ + row.title ?? "", + ...columns.map((column) => { + return propertyValueText(column.property, row.values.get(column.id)); + }), + ]); + return [...[header], ...data] + .map((row) => row.map(csvCell).join(",")) + .join("\r\n") + .concat("\r\n"); +} diff --git a/templates/content/shared/document-export.spec.ts b/templates/content/shared/document-export.spec.ts index c26c453f6a..22b555bc6f 100644 --- a/templates/content/shared/document-export.spec.ts +++ b/templates/content/shared/document-export.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { legacyBlocksFieldIdentity } from "./blocks-field-identity"; +import { renderDatabaseCsv } from "./database-csv-export"; import { buildDocumentExport, collectionItemsMarkdown, @@ -8,8 +9,90 @@ import { markdownWithTitle, } from "./document-export"; import { KATEX_STYLESHEET_URL } from "./math-rendering"; +import type { DocumentPropertyValue } from "./properties"; describe("document export", () => { + it("renders RFC 4180 CSV with formula-safe cells", () => { + expect( + renderDatabaseCsv( + [ + { + id: "notes", + name: "Notes, quoted", + property: { definition: { type: "text", options: {} } }, + }, + { + id: "amount", + name: "Amount", + property: { definition: { type: "text", options: {} } }, + }, + ], + [ + { + title: '=HYPERLINK("https://example.com")', + values: new Map([ + ["notes", 'hello, "world"\nnext'], + ["amount", null], + ]), + }, + ], + ), + ).toBe( + 'Title,"Notes, quoted",Amount\r\n"\'=HYPERLINK(""https://example.com"")","hello, ""world""\nnext",\r\n', + ); + }); + + it("uses option labels, array order, and checkbox text in CSV cells", () => { + expect( + renderDatabaseCsv( + [ + { + id: "status", + name: "Status", + property: { + definition: { + type: "status", + options: { + options: [{ id: "ready", name: "Ready", color: "green" }], + }, + }, + }, + }, + { + id: "tags", + name: "Tags", + property: { + definition: { + type: "multi_select", + options: { + options: [ + { id: "two", name: "Two", color: "blue" }, + { id: "one", name: "One", color: "gray" }, + ], + }, + }, + }, + }, + { + id: "done", + name: "Done", + property: { definition: { type: "checkbox", options: {} } }, + }, + ], + [ + { + title: "Row", + values: new Map([ + ["status", "ready"], + ["tags", ["two", "one"]], + ["done", true], + ]), + }, + ], + ), + ).toBe('Title,Status,Tags,Done\r\nRow,Ready,"Two, One",TRUE\r\n'); + }); + it("carries ordered Blocks fields in a non-rendering identity manifest", () => { const markdown = "Alpha\nBeta"; const blocksFields = [ From e7da938b182ecf309606b2f5d0340eebcb99d8b5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:00:33 -0400 Subject: [PATCH 2/7] fix(content): harden CSV formula neutralization --- templates/content/shared/database-csv-export.ts | 2 +- templates/content/shared/document-export.spec.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/templates/content/shared/database-csv-export.ts b/templates/content/shared/database-csv-export.ts index 2ff421dad2..eaa2a07f3e 100644 --- a/templates/content/shared/database-csv-export.ts +++ b/templates/content/shared/database-csv-export.ts @@ -36,7 +36,7 @@ export interface DatabaseCsvRow { } function csvCell(value: string): string { - const safe = /^[\t ]*[=+\-@]/.test(value) ? `'${value}` : value; + const safe = /^[\t\r\n ]*[=+\-@]/.test(value) ? `'${value}` : value; return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; } diff --git a/templates/content/shared/document-export.spec.ts b/templates/content/shared/document-export.spec.ts index 22b555bc6f..1df7e8022d 100644 --- a/templates/content/shared/document-export.spec.ts +++ b/templates/content/shared/document-export.spec.ts @@ -93,6 +93,18 @@ describe("document export", () => { ).toBe('Title,Status,Tags,Done\r\nRow,Ready,"Two, One",TRUE\r\n'); }); + it("neutralizes formulas after spreadsheet-trimmed line whitespace", () => { + expect( + renderDatabaseCsv( + [], + [ + { title: "\r=1+1", values: new Map() }, + { title: "\n@SUM(1,1)", values: new Map() }, + ], + ), + ).toBe('Title\r\n"\'\r=1+1"\r\n"\'\n@SUM(1,1)"\r\n'); + }); + it("carries ordered Blocks fields in a non-rendering identity manifest", () => { const markdown = "Alpha\nBeta"; const blocksFields = [ From f5bcd4d78bf1da30d2d694db1deb29dec450a204 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:55:24 -0400 Subject: [PATCH 3/7] fix(content): localize database export controls --- .../editor/database/DatabaseExportDialog.tsx | 2 +- templates/content/app/i18n-data.ts | 110 ++++++++++++++++++ templates/content/app/i18n/zh-TW.ts | 11 ++ 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseExportDialog.tsx b/templates/content/app/components/editor/database/DatabaseExportDialog.tsx index 0a64044fd6..8bcf08d80b 100644 --- a/templates/content/app/components/editor/database/DatabaseExportDialog.tsx +++ b/templates/content/app/components/editor/database/DatabaseExportDialog.tsx @@ -244,7 +244,7 @@ export function DatabaseExportDialog({ onClick={() => onOpenChange(false)} disabled={exportDocument.isPending} > - {t("editor.toolbar.cancel")} + {t("comments.cancel")}