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.layout.test.ts b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts index 0821d8003b..c5047a723f 100644 --- a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts @@ -239,6 +239,23 @@ describe("document database layout", () => { expect(source).toContain("hover:bg-muted/35 hover:text-foreground"); }); + it("does not publish an export context before database data is available", () => { + const source = readDatabaseSource(); + + expect(source).toContain("useMemo"); + expect(source).toContain("data\n ? {"); + expect(source).toContain("onExportContextChange?.(exportContext)"); + + const editorSource = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + { encoding: "utf8" }, + ); + expect(editorSource).toContain("databaseExportContextFingerprintRef"); + expect(editorSource).toContain( + "onExportContextChange={handleDatabaseExportContextChange}", + ); + }); + it("uses pill view tabs without a separate active chevron", () => { const source = readDatabaseSource(); 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..22854d5646 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,18 @@ function DocumentEditorBody({ const pushDocumentToNotion = usePushDocumentToNotion(documentId); const [localTitle, setLocalTitle] = useState(""); const [localContent, setLocalContent] = useState(""); + const [databaseExportContext, setDatabaseExportContext] = + useState(null); + const databaseExportContextFingerprintRef = useRef("null"); + const handleDatabaseExportContextChange = useCallback( + (context: DatabaseExportContext | null) => { + const fingerprint = JSON.stringify(context); + if (databaseExportContextFingerprintRef.current === fingerprint) return; + databaseExportContextFingerprintRef.current = fingerprint; + setDatabaseExportContext(context); + }, + [], + ); const [newDocumentTypeChosen, setNewDocumentTypeChosen] = useState(false); const [localContentUpdatedAt, setLocalContentUpdatedAt] = useState< string | null @@ -1759,6 +1772,7 @@ function DocumentEditorBody({ documentId={documentId} documentTitle={exportTitle} documentContent={exportContent} + databaseExportContext={databaseExportContext} breadcrumbItems={toolbarBreadcrumbItems.map((item) => item.id === documentId ? { ...item, title: exportTitle } : item, )} @@ -1896,7 +1910,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..a3816b0742 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -11,6 +11,8 @@ import type { DocumentSourceInfo } from "@shared/api"; import { IconArrowBarDown, IconArrowBarUp, + IconArrowBackUp, + IconArrowForwardUp, IconAlertTriangle, IconCheck, IconCopy, @@ -97,6 +99,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"; @@ -471,7 +477,12 @@ interface DocumentToolbarProps { utilityPanel: "info" | "comments" | null; onUtilityPanelChange: (panel: "info" | "comments" | null) => void; showCommentsControl?: boolean; + databaseExportContext?: DatabaseExportContext | null; onOpenBreadcrumbItem?: (id: string) => void; + canUndo?: boolean; + canRedo?: boolean; + onUndo?: () => void; + onRedo?: () => void; } export function DocumentToolbar({ @@ -493,7 +504,12 @@ export function DocumentToolbar({ utilityPanel, onUtilityPanelChange, showCommentsControl = true, + databaseExportContext, onOpenBreadcrumbItem, + canUndo = false, + canRedo = false, + onUndo, + onRedo, }: DocumentToolbarProps) { const t = useT(); const navigate = useNavigate(); @@ -530,6 +546,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(""); @@ -971,6 +988,17 @@ export function DocumentToolbar({ + + + + {t("editor.toolbar.undo")} + + + + {t("editor.toolbar.redo")} + + + void handleCopyPageLink()}> @@ -1018,7 +1046,7 @@ export function DocumentToolbar({ {source?.path} void handleRevealLocalPath()} > @@ -1029,6 +1057,7 @@ export function DocumentToolbar({ {t("editor.toolbar.copyRelativePath")} void handleCopyLocalAbsolutePath()} > @@ -1054,6 +1083,15 @@ export function DocumentToolbar({ {t("editor.toolbar.export")} + {databaseExportContext ? ( + setDatabaseExportOpen(true)} + > + + CSV + + ) : null} void handleExport("pdf")} @@ -1478,6 +1516,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..ae611321cc --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseExportDialog.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + databaseCsvRequest, + defaultDatabaseCsvPropertyIds, + shouldInitializeDatabaseExportDialog, + 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("initializes selections only when the dialog opens", () => { + expect(shouldInitializeDatabaseExportDialog(false, true)).toBe(true); + expect(shouldInitializeDatabaseExportDialog(true, true)).toBe(false); + expect(shouldInitializeDatabaseExportDialog(true, false)).toBe(false); + expect(shouldInitializeDatabaseExportDialog(false, false)).toBe(false); + }); + + 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..68eead6d61 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseExportDialog.tsx @@ -0,0 +1,277 @@ +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, useRef, 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 shouldInitializeDatabaseExportDialog( + wasOpen: boolean, + open: boolean, +) { + return open && !wasOpen; +} + +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 wasOpenRef = useRef(false); + const defaultPropertyIds = useMemo( + () => defaultDatabaseCsvPropertyIds(context?.properties ?? []), + [context], + ); + + useEffect(() => { + if (shouldInitializeDatabaseExportDialog(wasOpenRef.current, open)) { + setScope(defaultScope); + setPropertyIds(defaultPropertyIds); + } + wasOpenRef.current = open; + }, [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..5347bd1dce 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,45 @@ function DatabaseTable({ ), [orderedProperties, items, activeView], ); + const exportContext = useMemo( + () => + data + ? { + 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, + ), + })), + } + : null, + [ + activeView, + data, + 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..851469da99 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", @@ -11211,6 +11222,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "資訊", closeUtilityPanel: "關閉面板", + exportCsv: "匯出 CSV", + exportDatabase: "匯出資料庫", + exportScope: "匯出範圍", + currentView: "目前檢視", + allDatabaseMembers: "所有資料庫項目", + allDatabaseMembersDetail: "此資料庫中的每個頁面", + exportColumns: "匯出欄位", + titleColumn: "標題", + blocksColumn: "區塊", + exporting: "正在匯出...", + exportedCsv: "已匯出 CSV", }, }, comments: { @@ -11250,6 +11272,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "信息", closeUtilityPanel: "关闭面板", + exportCsv: "导出 CSV", + exportDatabase: "导出数据库", + exportScope: "导出范围", + currentView: "当前视图", + allDatabaseMembers: "所有数据库项目", + allDatabaseMembersDetail: "此数据库中的每个页面", + exportColumns: "导出列", + titleColumn: "标题", + blocksColumn: "内容块", + exporting: "正在导出...", + exportedCsv: "已导出 CSV", copiedPageLink: "已复制页面链接", copyPageLink: "复制页面链接", couldNotCopyLink: "无法复制链接", @@ -11297,6 +11330,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "Información", closeUtilityPanel: "Cerrar panel", + exportCsv: "Exportar CSV", + exportDatabase: "Exportar base de datos", + exportScope: "Ámbito de exportación", + currentView: "Vista actual", + allDatabaseMembers: "Todos los elementos de la base de datos", + allDatabaseMembersDetail: "Todas las páginas de esta base de datos", + exportColumns: "Columnas para exportar", + titleColumn: "Título", + blocksColumn: "Bloques", + exporting: "Exportando...", + exportedCsv: "CSV exportado", copiedPageLink: "Enlace de página copiado", copyPageLink: "Copiar enlace de página", couldNotCopyLink: "No se pudo copiar el enlace", @@ -11346,6 +11390,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "Informations", closeUtilityPanel: "Fermer le panneau", + exportCsv: "Exporter en CSV", + exportDatabase: "Exporter la base de données", + exportScope: "Périmètre d’exportation", + currentView: "Vue actuelle", + allDatabaseMembers: "Tous les éléments de la base de données", + allDatabaseMembersDetail: "Toutes les pages de cette base de données", + exportColumns: "Colonnes à exporter", + titleColumn: "Titre", + blocksColumn: "Blocs", + exporting: "Exportation...", + exportedCsv: "CSV exporté", copiedPageLink: "Lien de la page copié", copyPageLink: "Copier le lien de la page", couldNotCopyLink: "Impossible de copier le lien", @@ -11396,6 +11451,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "Informationen", closeUtilityPanel: "Bereich schließen", + exportCsv: "Als CSV exportieren", + exportDatabase: "Datenbank exportieren", + exportScope: "Exportumfang", + currentView: "Aktuelle Ansicht", + allDatabaseMembers: "Alle Datenbankeinträge", + allDatabaseMembersDetail: "Alle Seiten in dieser Datenbank", + exportColumns: "Zu exportierende Spalten", + titleColumn: "Titel", + blocksColumn: "Blöcke", + exporting: "Wird exportiert...", + exportedCsv: "CSV exportiert", copiedPageLink: "Seitenlink kopiert", copyPageLink: "Seitenlink kopieren", couldNotCopyLink: "Link konnte nicht kopiert werden", @@ -11445,6 +11511,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "情報", closeUtilityPanel: "パネルを閉じる", + exportCsv: "CSV をエクスポート", + exportDatabase: "データベースをエクスポート", + exportScope: "エクスポート範囲", + currentView: "現在のビュー", + allDatabaseMembers: "データベースの全項目", + allDatabaseMembersDetail: "このデータベース内のすべてのページ", + exportColumns: "エクスポートする列", + titleColumn: "タイトル", + blocksColumn: "ブロック", + exporting: "エクスポート中...", + exportedCsv: "CSV をエクスポートしました", copiedPageLink: "ページリンクをコピーしました", copyPageLink: "ページリンクをコピー", couldNotCopyLink: "リンクをコピーできませんでした", @@ -11493,6 +11570,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "정보", closeUtilityPanel: "패널 닫기", + exportCsv: "CSV 내보내기", + exportDatabase: "데이터베이스 내보내기", + exportScope: "내보내기 범위", + currentView: "현재 보기", + allDatabaseMembers: "모든 데이터베이스 항목", + allDatabaseMembersDetail: "이 데이터베이스의 모든 페이지", + exportColumns: "내보낼 열", + titleColumn: "제목", + blocksColumn: "블록", + exporting: "내보내는 중...", + exportedCsv: "CSV를 내보냈습니다", copiedPageLink: "페이지 링크를 복사했습니다", copyPageLink: "페이지 링크 복사", couldNotCopyLink: "링크를 복사하지 못했습니다", @@ -11542,6 +11630,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "Informações", closeUtilityPanel: "Fechar painel", + exportCsv: "Exportar CSV", + exportDatabase: "Exportar banco de dados", + exportScope: "Escopo da exportação", + currentView: "Visualização atual", + allDatabaseMembers: "Todos os itens do banco de dados", + allDatabaseMembersDetail: "Todas as páginas deste banco de dados", + exportColumns: "Colunas para exportar", + titleColumn: "Título", + blocksColumn: "Blocos", + exporting: "Exportando...", + exportedCsv: "CSV exportado", copiedPageLink: "Link da página copiado", copyPageLink: "Copiar link da página", couldNotCopyLink: "Não foi possível copiar o link", @@ -11590,6 +11689,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "जानकारी", closeUtilityPanel: "पैनल बंद करें", + exportCsv: "CSV निर्यात करें", + exportDatabase: "डेटाबेस निर्यात करें", + exportScope: "निर्यात का दायरा", + currentView: "वर्तमान दृश्य", + allDatabaseMembers: "डेटाबेस के सभी आइटम", + allDatabaseMembersDetail: "इस डेटाबेस के सभी पेज", + exportColumns: "निर्यात किए जाने वाले कॉलम", + titleColumn: "शीर्षक", + blocksColumn: "ब्लॉक", + exporting: "निर्यात हो रहा है...", + exportedCsv: "CSV निर्यात किया गया", copiedPageLink: "पेज लिंक कॉपी किया गया", copyPageLink: "पेज लिंक कॉपी करें", couldNotCopyLink: "लिंक कॉपी नहीं किया जा सका", @@ -11636,6 +11746,17 @@ const contentExactEnglishTranslations = { toolbar: { info: "معلومات", closeUtilityPanel: "إغلاق اللوحة", + exportCsv: "تصدير CSV", + exportDatabase: "تصدير قاعدة البيانات", + exportScope: "نطاق التصدير", + currentView: "العرض الحالي", + allDatabaseMembers: "كل عناصر قاعدة البيانات", + allDatabaseMembersDetail: "كل الصفحات في قاعدة البيانات هذه", + exportColumns: "الأعمدة المراد تصديرها", + titleColumn: "العنوان", + blocksColumn: "الكتل", + exporting: "جارٍ التصدير...", + exportedCsv: "تم تصدير CSV", copiedPageLink: "تم نسخ رابط الصفحة", copyPageLink: "نسخ رابط الصفحة", couldNotCopyLink: "تعذر نسخ الرابط", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index ad9ebd6a36..fdfe90d487 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -507,6 +507,17 @@ const messages = { clipboardAccessUnavailable: "此瀏覽器無法存取剪貼簿。", edited: "已編輯", export: "出口", + exportCsv: "匯出 CSV", + exportDatabase: "匯出資料庫", + exportScope: "匯出範圍", + currentView: "目前檢視", + allDatabaseMembers: "所有資料庫項目", + allDatabaseMembersDetail: "此資料庫中的每個頁面", + exportColumns: "匯出欄位", + titleColumn: "標題", + blocksColumn: "區塊", + exporting: "正在匯出...", + exportedCsv: "已匯出 CSV", exportFailed: "匯出失敗", exportedHtml: "匯出的 HTML", exportedMarkdown: "匯出的 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..eaa2a07f3e --- /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\r\n ]*[=+\-@]/.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..1df7e8022d 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,102 @@ 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("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 = [