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 (
+
+ );
+}
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")}