feat(docs): export data dictionary as PDF

This commit is contained in:
onenewcode authored and GitHub committed 2026-09-24 19:53:58 +08:00
1 parent 2d4ee24660
commit b140f84ac4
28 files changed
+3108 -3

No files matched your search

@@ -0,0 +1,613 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { emptyAnnotations } from "@/docs/annotationEdits";
import type { SchemaSnapshot, SnapshotWarning, TableKind } from "@/docs/types";
import * as api from "@/lib/backend/api";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import {
applyTemplate,
dataDictionaryFileName,
dictionaryCatalogNames,
exportBlockedBySkippedTables,
isDictionaryProfile,
objectKey,
orderDictionaryTables,
resolveDictionaryExportPath,
tableKindFrom,
warningsForSelection,
toggleOrderedKey,
moveOrderedKey,
type DataDictionaryLabels,
type DictionaryLayout,
type DictionaryObjectRef,
type DictionaryProfile,
type DictionaryTable,
type DictionaryTemplateId,
} from "@/lib/docs/dataDictionary";
import { buildDataDictionaryPdf } from "@/lib/docs/dataDictionaryPdf";
import { DictionaryFileExistsError, saveDataDictionaryFile } from "@/lib/docs/saveDataDictionaryFile";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTableNames?: string[];
snapshot?: SchemaSnapshot | null;
}>();
const open = defineModel<boolean>("open", { default: false });
const { t } = useI18n();
const connectionStore = useConnectionStore();
const step = ref(0);
const loading = ref(false);
const exporting = ref(false);
const loadError = ref<string | null>(null);
const exportError = ref<string | null>(null);
const generatedPath = ref("");
const elapsed = ref(0);
const databases = ref<Array<{ name: string; checked: boolean }>>([]);
const schemas = ref<Array<{ database: string; name: string; checked: boolean }>>([]);
const objects = ref<DictionaryObjectRef[]>([]);
const order = ref<string[]>([]);
const objectSearch = ref("");
const templateId = ref<DictionaryTemplateId>("standard");
const layout = ref<DictionaryLayout>(blankLayout());
const appendTimestamp = ref(false);
const overwrite = ref(true);
const continueOnError = ref(true);
const outputPath = ref("");
const previewUrl = ref("");
let generation = 0;
let elapsedTimer = 0;
const steps = computed(() => [t("dataDictionary.stepDatabases"), t("dataDictionary.stepObjects"), t("dataDictionary.stepTemplate"), t("dataDictionary.stepLayout"), t("dataDictionary.stepFile")]);
const dbType = computed(() => connectionStore.getConfig(props.prefillConnectionId || "")?.db_type);
const schemaAware = computed(() => isSchemaAware(dbType.value));
const checkedDatabases = computed(() => databases.value.filter((item) => item.checked).map((item) => item.name));
const checkedSchemas = computed(() => schemas.value.filter((item) => item.checked && checkedDatabases.value.includes(item.database)));
const filteredObjects = computed(() => {
const query = objectSearch.value.trim().toLowerCase();
return objects.value.filter((item) => !query || `${item.database} ${item.schema} ${item.name}`.toLowerCase().includes(query));
});
const orderedObjects = computed(() => order.value.flatMap((key) => objects.value.filter((item) => objectKey(item) === key)));
const canNext = computed(() => {
if (loading.value) return false;
if (step.value === 0) return checkedDatabases.value.length > 0 && (!schemaAware.value || checkedSchemas.value.length > 0 || schemas.value.length === 0);
if (step.value === 1) return order.value.length > 0;
return true;
});
function blankLayout(): DictionaryLayout {
return applyTemplate("standard", { title: "", introduction: "", detailedIntroduction: "", leftFooter: "" });
}
function labels(): DataDictionaryLabels {
return {
column: t("dataDictionary.column"),
type: t("dataDictionary.type"),
length: t("dataDictionary.length"),
precision: t("dataDictionary.precision"),
scale: t("dataDictionary.scale"),
primaryKey: t("dataDictionary.primaryKey"),
nullable: t("dataDictionary.nullable"),
unique: t("dataDictionary.unique"),
defaultValue: t("dataDictionary.defaultValue"),
extra: t("dataDictionary.extra"),
comment: t("dataDictionary.comment"),
indexName: t("dataDictionary.indexName"),
indexColumns: t("dataDictionary.indexColumns"),
indexType: t("dataDictionary.indexType"),
constraintName: t("dataDictionary.constraintName"),
refSchema: t("dataDictionary.refSchema"),
refTable: t("dataDictionary.refTable"),
refColumn: t("dataDictionary.refColumn"),
onUpdate: t("dataDictionary.onUpdate"),
onDelete: t("dataDictionary.onDelete"),
yes: t("dataDictionary.yes"),
no: t("dataDictionary.no"),
indexesHeading: t("dataDictionary.indexesHeading"),
foreignKeysHeading: t("dataDictionary.foreignKeysHeading"),
contentsHeading: t("dataDictionary.contentsHeading"),
kindTable: t("dataDictionary.kindTable"),
kindView: t("dataDictionary.kindView"),
kindMaterializedView: t("dataDictionary.kindMaterializedView"),
};
}
function kindLabel(kind: TableKind): string {
if (kind === "VIEW") return t("dataDictionary.kindView");
if (kind === "MATERIALIZED_VIEW") return t("dataDictionary.kindMaterializedView");
return t("dataDictionary.kindTable");
}
function objectLabel(item: DictionaryObjectRef): string {
const schema = item.schema ? `${item.schema}.` : "";
return `${item.database}.${schema}${item.name}`;
}
function warningText(warning: SnapshotWarning): string {
switch (warning.kind) {
case "tableSkipped":
return t("dataDictionary.warningTableSkipped", { table: warning.table, reason: warning.reason });
case "noForeignKeyMetadata":
return t("dataDictionary.warningNoForeignKeys", { engine: warning.engine });
case "commentsUnsupported":
return t("dataDictionary.warningCommentsUnsupported", { engine: warning.engine });
default:
return "";
}
}
function chooseTemplate(id: DictionaryTemplateId): void {
templateId.value = id;
const title = checkedDatabases.value.join(", ") || props.prefillDatabase || "Data Dictionary";
layout.value = applyTemplate(id, {
title,
introduction: t("dataDictionary.introductionDefault"),
detailedIntroduction: `${t("dataDictionary.introductionDefault")}\n${t("dataDictionary.detailedIntroduction")}`,
leftFooter: title,
});
}
async function loadDatabases(): Promise<void> {
const mine = ++generation;
loading.value = true;
loadError.value = null;
databases.value = [];
schemas.value = [];
try {
const connectionId = props.prefillConnectionId;
if (!connectionId) throw new Error(t("dataDictionary.empty"));
await connectionStore.ensureConnected(connectionId);
if (mine !== generation) return;
const listed = await api.listDatabases(connectionId);
if (mine !== generation) return;
const names = dictionaryCatalogNames(
listed.map((item) => item.name).filter((name) => name !== ""),
dbType.value,
props.prefillDatabase,
);
const preferred = props.prefillDatabase && names.includes(props.prefillDatabase) ? props.prefillDatabase : names[0];
databases.value = (names.length ? names : [props.prefillDatabase || ""].filter(Boolean)).map((name) => ({ name, checked: name === preferred }));
await loadSchemas();
} catch (error) {
if (mine === generation) loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
} finally {
if (mine === generation) loading.value = false;
}
}
async function toggleDatabase(item: { name: string; checked: boolean }, checked: boolean): Promise<void> {
item.checked = checked;
await loadSchemas();
}
async function loadSchemas(): Promise<void> {
if (!schemaAware.value) {
schemas.value = [];
return;
}
const connectionId = props.prefillConnectionId;
if (!connectionId) return;
const previous = new Set(schemas.value.filter((item) => item.checked).map((item) => `${item.database}.${item.name}`));
const firstLoad = schemas.value.length === 0;
const next: Array<{ database: string; name: string; checked: boolean }> = [];
for (const database of checkedDatabases.value) {
const names = dictionaryCatalogNames(await api.listSchemas(connectionId, database), dbType.value, database === props.prefillDatabase ? props.prefillSchema : undefined);
for (const name of names) {
const key = `${database}.${name}`;
const prefilled = !props.prefillSchema || (database === props.prefillDatabase && name === props.prefillSchema);
next.push({ database, name, checked: firstLoad ? prefilled : previous.has(key) });
}
}
if (firstLoad && props.prefillSchema && next.length > 0 && !next.some((item) => item.checked)) next.forEach((item) => (item.checked = true));
schemas.value = next;
}
async function loadObjects(): Promise<void> {
const connectionId = props.prefillConnectionId;
if (!connectionId) return;
loading.value = true;
loadError.value = null;
try {
const targets = schemaAware.value ? checkedSchemas.value.map((item) => ({ database: item.database, schema: item.name })) : checkedDatabases.value.map((database) => ({ database, schema: "" }));
const listed: DictionaryObjectRef[] = [];
for (const target of targets) {
const tables = await api.listTables(connectionId, target.database, target.schema, undefined, undefined, undefined, ["TABLE", "VIEW", "MATERIALIZED_VIEW"] satisfies SidebarObjectKind[]);
for (const table of tables) {
listed.push({ database: target.database, schema: target.schema || table.parent_schema || "", name: table.name, kind: tableKindFrom(table.table_type) });
}
}
objects.value = listed;
const wanted = new Set(props.prefillTableNames ?? []);
const initial = listed.filter((item) => (wanted.size > 0 ? wanted.has(item.name) : item.kind === "TABLE"));
order.value = (initial.length ? initial : listed).map(objectKey);
} catch (error) {
loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
objects.value = [];
order.value = [];
} finally {
loading.value = false;
}
}
async function collectSelection(): Promise<{ tables: DictionaryTable[]; warnings: string[] }> {
const connectionId = props.prefillConnectionId;
if (!connectionId) throw new Error(t("dataDictionary.empty"));
const warnings: string[] = [];
const tables: DictionaryTable[] = [];
if (props.snapshot) {
const database = props.snapshot.project.database || props.prefillDatabase || "";
const fromSnapshot = orderDictionaryTables(
props.snapshot.tables.map((table) => ({ ...table, database })),
order.value,
);
if (fromSnapshot.length === order.value.length) {
const relevant = warningsForSelection(props.snapshot.warnings, orderedObjects.value);
if (exportBlockedBySkippedTables(relevant, continueOnError.value)) {
throw new Error(relevant.map(warningText).find(Boolean) || database);
}
return { tables: fromSnapshot, warnings: relevant.map(warningText).filter(Boolean) };
}
}
for (const database of checkedDatabases.value) {
const schemaNames = schemaAware.value ? checkedSchemas.value.filter((item) => item.database === database).map((item) => item.name) : [];
const tableNames = orderedObjects.value.filter((item) => item.database === database).map((item) => (item.schema ? `${item.schema}.${item.name}` : item.name));
if (tableNames.length === 0) continue;
try {
const raw = await api.collectDocsSnapshot(connectionId, database, schemaNames, tableNames, database);
const file = (await api.loadDocsAnnotations(connectionId)) ?? emptyAnnotations();
const merged = await api.applyDocsAnnotations(connectionId, raw, file);
const relevant = warningsForSelection(merged.warnings, orderedObjects.value);
if (exportBlockedBySkippedTables(relevant, continueOnError.value)) throw new Error(relevant.map(warningText).find(Boolean) || database);
warnings.push(...relevant.map(warningText).filter(Boolean));
tables.push(...merged.tables.map((table) => ({ ...table, database })));
} catch (error) {
if (!continueOnError.value) throw error;
warnings.push(t("dataDictionary.warningTableSkipped", { table: database, reason: error instanceof Error ? error.message : String(error) }));
}
}
return { tables: orderDictionaryTables(tables, order.value), warnings };
}
async function refreshPreview(): Promise<void> {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
try {
const document = await collectSelection();
const bytes = buildDataDictionaryPdf(document.tables, labels(), layout.value, document.warnings);
previewUrl.value = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer], { type: "application/pdf" }));
} catch (error) {
exportError.value = t("dataDictionary.previewFailed", { error: error instanceof Error ? error.message : String(error) });
}
}
async function goNext(): Promise<void> {
exportError.value = null;
if (step.value === 0) {
await loadObjects();
if (loadError.value || order.value.length === 0) return;
chooseTemplate(templateId.value);
}
if (step.value === 1 && order.value.length === 0) return;
const enteringLayout = step.value === 2;
if (step.value < 4) step.value += 1;
if (enteringLayout) await refreshPreview();
}
async function browse(): Promise<void> {
if (!isTauriRuntime()) return;
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: dataDictionaryFileName(checkedDatabases.value[0] || "DataDictionary", appendTimestamp.value), filters: [{ name: "PDF", extensions: ["pdf"] }] });
if (path) outputPath.value = path;
}
async function exportDictionary(): Promise<void> {
exporting.value = true;
exportError.value = null;
generatedPath.value = "";
elapsed.value = 0;
elapsedTimer = window.setInterval(() => (elapsed.value += 1), 1000);
try {
const fallback = dataDictionaryFileName(checkedDatabases.value[0] || "DataDictionary", false);
const suggested = resolveDictionaryExportPath(outputPath.value, fallback, appendTimestamp.value);
const document = await collectSelection();
const bytes = buildDataDictionaryPdf(document.tables, labels(), layout.value, continueOnError.value ? document.warnings : []);
const saved = await saveDataDictionaryFile(suggested, bytes, { overwrite: overwrite.value });
if (saved) generatedPath.value = saved;
} catch (error) {
exportError.value = error instanceof DictionaryFileExistsError ? t("dataDictionary.fileExists") : t("dataDictionary.exportFailed", { error: error instanceof Error ? error.message : String(error) });
} finally {
window.clearInterval(elapsedTimer);
exporting.value = false;
}
}
function profilePayload(): DictionaryProfile {
return {
version: 1,
databases: checkedDatabases.value,
schemas: checkedSchemas.value.map((item) => ({ database: item.database, name: item.name })),
objects: orderedObjects.value.map((item) => ({ database: item.database, schema: item.schema, name: item.name })),
layout: layout.value,
appendTimestamp: appendTimestamp.value,
overwrite: overwrite.value,
continueOnError: continueOnError.value,
fileName: outputPath.value,
};
}
async function saveProfile(): Promise<void> {
const content = JSON.stringify(profilePayload(), null, 2);
if (isTauriRuntime()) {
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await save({ defaultPath: "data-dictionary.json", filters: [{ name: "JSON", extensions: ["json"] }] });
if (path) await fs.writeTextFile(path, content);
return;
}
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "data-dictionary.json";
anchor.click();
URL.revokeObjectURL(url);
}
async function loadProfile(): Promise<void> {
try {
const text = isTauriRuntime() ? await readProfileFromDialog() : await readProfileFromBrowser();
if (!text) return;
const parsed: unknown = JSON.parse(text);
if (!isDictionaryProfile(parsed)) throw new Error(t("dataDictionary.profileInvalid"));
layout.value = parsed.layout;
appendTimestamp.value = parsed.appendTimestamp;
overwrite.value = parsed.overwrite;
continueOnError.value = parsed.continueOnError;
outputPath.value = parsed.fileName;
databases.value = databases.value.map((item) => ({ ...item, checked: parsed.databases.includes(item.name) }));
await loadSchemas();
schemas.value = schemas.value.map((item) => ({ ...item, checked: parsed.schemas.some((schema) => schema.database === item.database && schema.name === item.name) }));
await loadObjects();
order.value = parsed.objects.map((item) => objectKey(item)).filter((key) => objects.value.some((item) => objectKey(item) === key));
} catch (error) {
loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
}
}
async function readProfileFromDialog(): Promise<string | null> {
const [{ open: pick }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await pick({ filters: [{ name: "JSON", extensions: ["json"] }] });
if (!path || Array.isArray(path)) return null;
return fs.readTextFile(path);
}
function readProfileFromBrowser(): Promise<string | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.onchange = () => {
const file = input.files?.[0];
if (!file) return resolve(null);
void file.text().then(resolve);
};
input.click();
});
}
function setFontSize(size: "small" | "medium" | "large"): void {
const scale = size === "small" ? 0.85 : size === "large" ? 1.15 : 1;
layout.value = { ...layout.value, headingSize: Math.round(16 * scale), bodySize: Math.round(9 * scale) };
}
watch(
open,
(isOpen) => {
if (!isOpen) {
generation += 1;
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
return;
}
step.value = 0;
generatedPath.value = "";
exportError.value = null;
outputPath.value = "";
appendTimestamp.value = false;
overwrite.value = true;
continueOnError.value = true;
templateId.value = "standard";
void loadDatabases();
},
{ immediate: true },
);
onBeforeUnmount(() => {
generation += 1;
window.clearInterval(elapsedTimer);
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex h-[min(760px,88vh)] w-[min(980px,94vw)] max-w-[94vw] flex-col gap-0 p-0 sm:max-w-[94vw]">
<DialogHeader class="border-b px-4 py-3 pr-12">
<DialogTitle>{{ t("dataDictionary.title") }}</DialogTitle>
<DialogDescription>{{ steps[step] }}</DialogDescription>
</DialogHeader>
<div class="flex items-center gap-2 border-b px-4 py-2 text-xs">
<span v-for="(label, index) in steps" :key="label" :class="index === step ? 'font-medium text-foreground' : 'text-muted-foreground'">{{ index + 1 }}. {{ label }}</span>
<span class="ml-auto flex gap-2">
<Button type="button" size="sm" variant="outline" @click="loadProfile()">{{ t("dataDictionary.loadProfile") }}</Button>
<Button type="button" size="sm" variant="outline" @click="saveProfile()">{{ t("dataDictionary.saveProfile") }}</Button>
</span>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<p v-if="loading" class="text-sm text-muted-foreground">{{ t("dataDictionary.loading") }}</p>
<p v-if="loadError" class="mb-2 text-sm text-destructive">{{ loadError }}</p>
<div v-if="step === 0" class="grid gap-4 md:grid-cols-2">
<section>
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.databases") }}</h3>
<label v-for="item in databases" :key="item.name" class="flex items-center gap-2 py-1 text-sm">
<input type="checkbox" :checked="item.checked" @change="toggleDatabase(item, ($event.target as HTMLInputElement).checked)" />
<span>{{ item.name }}</span>
</label>
</section>
<section v-if="schemaAware">
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.schemasHeading") }}</h3>
<p v-if="schemas.length === 0" class="text-sm text-muted-foreground">{{ t("dataDictionary.noSchemaLevel") }}</p>
<label v-for="item in schemas" :key="`${item.database}.${item.name}`" class="flex items-center gap-2 py-1 text-sm">
<input v-model="item.checked" type="checkbox" />
<span>{{ item.database }}.{{ item.name }}</span>
</label>
</section>
</div>
<div v-else-if="step === 1" class="grid h-full gap-3 md:grid-cols-2">
<section class="flex min-h-0 flex-col gap-2">
<div class="flex gap-2">
<Input v-model="objectSearch" :placeholder="t('dataDictionary.searchObjects')" />
<Button type="button" size="sm" variant="outline" @click="order = filteredObjects.map(objectKey)">{{ t("dataDictionary.selectAll") }}</Button>
<Button type="button" size="sm" variant="outline" @click="order = order.filter((key) => !filteredObjects.some((item) => objectKey(item) === key))">{{ t("dataDictionary.selectNone") }}</Button>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded border">
<label v-for="item in filteredObjects" :key="objectKey(item)" class="flex items-center gap-2 border-b px-2 py-1 text-sm">
<input type="checkbox" :checked="order.includes(objectKey(item))" @change="order = toggleOrderedKey(order, objectKey(item), ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate">{{ objectLabel(item) }}</span>
<span class="text-xs text-muted-foreground">{{ kindLabel(item.kind) }}</span>
</label>
</div>
</section>
<section>
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.selectedCount", { count: order.length }) }}</h3>
<div class="max-h-[420px] overflow-auto rounded border">
<div v-for="item in orderedObjects" :key="objectKey(item)" class="flex items-center gap-2 border-b px-2 py-1 text-sm">
<span class="min-w-0 flex-1 truncate">{{ objectLabel(item) }}</span>
<Button type="button" size="sm" variant="outline" @click="order = moveOrderedKey(order, objectKey(item), -1)">{{ t("dataDictionary.moveUp") }}</Button>
<Button type="button" size="sm" variant="outline" @click="order = moveOrderedKey(order, objectKey(item), 1)">{{ t("dataDictionary.moveDown") }}</Button>
</div>
</div>
</section>
</div>
<div v-else-if="step === 2" class="grid gap-2">
<button v-for="id in ['standard', 'detailed', 'compact', 'catalog', 'reference']" :key="id" type="button" class="rounded border px-3 py-2 text-left" :class="templateId === id ? 'border-foreground bg-muted' : 'border-border'" @click="chooseTemplate(id as DictionaryTemplateId)">
<span class="block text-sm font-medium">{{ t(`dataDictionary.template${id[0]!.toUpperCase()}${id.slice(1)}`) }}</span>
<span class="text-xs text-muted-foreground">{{ t(`dataDictionary.template${id[0]!.toUpperCase()}${id.slice(1)}Hint`) }}</span>
</button>
</div>
<div v-else-if="step === 3" class="grid h-full gap-3 md:grid-cols-[280px_minmax(0,1fr)]">
<div class="flex flex-col gap-3 overflow-auto pr-1">
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeCover") }}</span
><input v-model="layout.includeCover" type="checkbox"
/></label>
<Label>{{ t("dataDictionary.header") }}</Label>
<Input v-model="layout.header" />
<Label>{{ t("dataDictionary.dictionaryTitle") }}</Label>
<Input v-model="layout.title" />
<Label>{{ t("dataDictionary.subtitle") }}</Label>
<Input v-model="layout.subtitle" />
<Label>{{ t("dataDictionary.remarks") }}</Label>
<Input v-model="layout.remarks" />
<Label>{{ t("dataDictionary.coverFooter") }}</Label>
<Input v-model="layout.coverFooter" />
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeToc") }}</span
><input v-model="layout.includeToc" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeBreadcrumbs") }}</span
><input v-model="layout.includeBreadcrumbs" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeLeftFooter") }}</span
><input v-model="layout.includeLeftFooter" type="checkbox"
/></label>
<Input v-model="layout.leftFooter" />
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includePageNumber") }}</span
><input v-model="layout.includePageNumber" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeIntroduction") }}</span
><input v-model="layout.includeIntroduction" type="checkbox"
/></label>
<textarea v-model="layout.introduction" class="min-h-24 rounded border bg-transparent p-2 text-sm" />
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.includeIndexes") }}</span
><input v-model="layout.includeIndexesAndForeignKeys" type="checkbox"
/></label>
<Label>{{ t("dataDictionary.paper") }}</Label>
<select v-model="layout.paper" class="rounded border bg-transparent px-2 py-1 text-sm">
<option value="A4">A4</option>
<option value="A3">A3</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
<Label>{{ t("dataDictionary.orientation") }}</Label>
<select v-model="layout.orientation" class="rounded border bg-transparent px-2 py-1 text-sm">
<option value="portrait">{{ t("dataDictionary.portrait") }}</option>
<option value="landscape">{{ t("dataDictionary.landscape") }}</option>
</select>
<Label>{{ t("dataDictionary.margin") }}</Label>
<Input v-model.number="layout.marginCm" type="number" min="0.5" max="4" step="0.1" />
<Label>{{ t("dataDictionary.fontSize") }}</Label>
<select class="rounded border bg-transparent px-2 py-1 text-sm" @change="setFontSize(($event.target as HTMLSelectElement).value as 'small' | 'medium' | 'large')">
<option value="medium">{{ t("dataDictionary.fontMedium") }}</option>
<option value="small">{{ t("dataDictionary.fontSmall") }}</option>
<option value="large">{{ t("dataDictionary.fontLarge") }}</option>
</select>
<Button type="button" variant="outline" @click="refreshPreview()">{{ t("dataDictionary.preview") }}</Button>
</div>
<iframe v-if="previewUrl" :src="previewUrl" class="h-full min-h-80 w-full rounded border" :title="t('dataDictionary.preview')" />
<p v-else class="text-sm text-muted-foreground">{{ t("dataDictionary.preview") }}</p>
</div>
<div v-else class="flex max-w-xl flex-col gap-3">
<Label>{{ t("dataDictionary.exportToFile") }}</Label>
<div class="flex gap-2">
<Input v-model="outputPath" :placeholder="dataDictionaryFileName(checkedDatabases[0] || 'DataDictionary', appendTimestamp)" />
<Button v-if="isTauriRuntime()" type="button" variant="outline" @click="browse()">{{ t("dataDictionary.browse") }}</Button>
</div>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.appendTimestamp") }}</span
><input v-model="appendTimestamp" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.overwriteExisting") }}</span
><input v-model="overwrite" type="checkbox"
/></label>
<label class="flex items-center justify-between gap-2 text-sm"
><span>{{ t("dataDictionary.continueOnError") }}</span
><input v-model="continueOnError" type="checkbox"
/></label>
<p v-if="exporting" class="text-sm text-muted-foreground">{{ t("dataDictionary.generating") }} {{ elapsed }}s</p>
<p v-if="generatedPath" class="text-sm">{{ t("dataDictionary.generated") }} {{ generatedPath }}</p>
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
</div>
</div>
<DialogFooter class="border-t px-4 py-3">
<Button v-if="step > 0" type="button" variant="outline" @click="step -= 1">{{ t("dataDictionary.back") }}</Button>
<Button v-if="step < 4" type="button" :disabled="!canNext" @click="goNext()">{{ t("dataDictionary.next") }}</Button>
<Button v-else type="button" :disabled="exporting || order.length === 0" @click="exportDictionary()">{{ exporting ? t("dataDictionary.exporting") : t("dataDictionary.start") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Download, Network } from "@lucide/vue";
import { Download, FileText, Network } from "@lucide/vue";
import DataDictionaryDialog from "@/components/docs/DataDictionaryDialog.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DocsApp from "@/docs/DocsApp.vue";
@@ -77,6 +78,7 @@ const canOpenDiagram = computed(() => (props.prefillConnectionId ?? "") !== "" &
const exporting = ref(false);
const exportError = ref<string | null>(null);
const showDataDictionary = ref(false);
async function load(): Promise<void> {
const connectionId = props.prefillConnectionId;
@@ -208,7 +210,7 @@ watch(
<Dialog v-model:open="open">
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
<DialogHeader class="px-4 py-3 border-b pr-12">
<DialogTitle class="flex items-center gap-2">
<DialogTitle class="flex flex-wrap items-center gap-2">
<span>{{ t("docs.title") }}</span>
<span v-if="statusLabel" class="text-xs font-normal" :class="status.state === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ statusLabel }}
@@ -218,6 +220,10 @@ watch(
<Network class="w-4 h-4" />
{{ t("docs.openDiagram") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" @click="showDataDictionary = true">
<FileText class="w-4 h-4" />
{{ t("dataDictionary.title") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" :disabled="exporting" @click="exportHtml()">
<Download class="w-4 h-4" />
{{ exporting ? t("docs.exporting") : t("docs.exportHtml") }}
@@ -232,4 +238,5 @@ watch(
</div>
</DialogContent>
</Dialog>
<DataDictionaryDialog v-if="showDataDictionary && snapshot" v-model:open="showDataDictionary" :prefill-connection-id="prefillConnectionId" :prefill-database="prefillDatabase" :prefill-schema="prefillSchema" :snapshot="snapshot" />
</template>
@@ -13,6 +13,7 @@ const DataCompareDialog = defineAsyncComponent(() => import("@/components/diff/D
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
const DatabaseDocsDialog = defineAsyncComponent(() => import("@/components/docs/DatabaseDocsDialog.vue"));
const DataDictionaryDialog = defineAsyncComponent(() => import("@/components/docs/DataDictionaryDialog.vue"));
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
const MongoImportDialog = defineAsyncComponent(() => import("@/components/document/MongoImportDialog.vue"));
const MongoDatabaseDumpDialog = defineAsyncComponent(() => import("@/components/document/MongoDatabaseDumpDialog.vue"));
@@ -280,6 +281,14 @@ watch(
@open-target="emit('openDiagramTarget', $event)"
/>
<DatabaseDocsDialog v-if="dialogs.showDocsDialog.value" v-model:open="dialogs.showDocsDialog.value" :prefill-connection-id="dialogs.docsPrefillConnectionId.value" :prefill-database="dialogs.docsPrefillDatabase.value" :prefill-schema="dialogs.docsPrefillSchema.value" />
<DataDictionaryDialog
v-if="dialogs.showDataDictionaryDialog.value"
v-model:open="dialogs.showDataDictionaryDialog.value"
:prefill-connection-id="dialogs.dataDictionaryPrefillConnectionId.value"
:prefill-database="dialogs.dataDictionaryPrefillDatabase.value"
:prefill-schema="dialogs.dataDictionaryPrefillSchema.value"
:prefill-table-names="dialogs.dataDictionaryPrefillTableNames.value"
/>
<TableImportDialog
v-if="dialogs.showTableImportDialog.value"
v-model:open="dialogs.showTableImportDialog.value"
@@ -24,6 +24,7 @@ import {
Eraser,
Eye,
FileCode,
FileText,
Info,
GripVertical,
KeyRound,
@@ -75,7 +76,7 @@ import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, PgTablePartitioning, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { supportsAiAssistantContext, supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { supportsAiAssistantContext, supportsDataDictionary, supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { findTableStatistics } from "@/lib/dataGrid/tableInfoOverview";
@@ -409,6 +410,7 @@ const objectCounts = computed(() => countObjectBrowserRowsByFilter(rows.value));
const objectSearchSummary = computed(() => summarizeObjectBrowserSearch(rows.value, search.value));
const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tableStructureDatabaseType.value));
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
const canOpenDataDictionary = computed(() => !!props.database && supportsDataDictionary(effectiveDatabaseType.value));
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value));
const supportsVacuumTable = computed(() => !connectionIsEffectivelyReadOnly(props.connection) && supportsTableVacuum(effectiveDatabaseType.value));
@@ -1995,6 +1997,15 @@ function openDataCompare(row: ObjectBrowserRow) {
};
}
function openDataDictionary(row: ObjectBrowserRow) {
connectionStore.dataDictionarySource = {
connectionId: props.connection.id,
database: props.database,
schema: row.schema || selectedSchema.value,
tableNames: [row.name],
};
}
function openDatabaseExport(row: ObjectBrowserRow) {
connectionStore.databaseExportSource = {
connectionId: props.connection.id,
@@ -3450,6 +3461,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
exportDataSubmenu(item),
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Upload },
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
...(canOpenDataDictionary.value ? [{ label: t("dataDictionary.title"), action: () => openDataDictionary(item), icon: FileText }] : []),
{ label: "", separator: true },
{ label: t("contextMenu.duplicateStructure"), action: () => requestDuplicateStructure(item), icon: CopyPlus },
...tableClipboardMenuItems(item),
@@ -3478,6 +3490,7 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
exportDataSubmenu(item),
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Upload },
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
...(canOpenDataDictionary.value ? [{ label: t("dataDictionary.title"), action: () => openDataDictionary(item), icon: FileText }] : []),
{ label: "", separator: true },
{
label: t("contextMenu.dropView"),
@@ -34,6 +34,7 @@ import {
Eye,
Upload,
FileCode,
FileText,
Network,
PencilRuler,
Search,
@@ -113,6 +114,7 @@ import {
supportsAiAssistantContext,
supportsFieldLineage,
supportsObjectBrowserTreeNode,
supportsDataDictionary,
supportsSchemaDiagram,
supportsSqlFileExecution,
supportsTableImport,
@@ -434,6 +436,7 @@ const {
openDataCompare,
openDatabaseExport,
openDatabaseSearch,
openDataDictionary,
openDiagram,
openDocs,
openFieldLineage,
@@ -4704,6 +4707,10 @@ const canOpenDiagram = computed(() => {
return !!activeNode.value.database && supportsSchemaDiagram(currentDatabaseType());
});
const canOpenDataDictionary = computed(() => {
return !!activeNode.value.connectionId && !!activeNode.value.database && supportsDataDictionary(currentDatabaseType());
});
const canOpenDatabaseSearch = computed(() => {
return !!activeNode.value.database && supportsDatabaseSearch(currentDatabaseType());
});
@@ -5878,6 +5885,9 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("diff.title"), action: openSchemaDiff, icon: ArrowRightLeft });
items.push({ label: t("dataCompare.title"), action: openDataCompare, icon: ArrowRightLeft });
items.push({ label: t("contextMenu.exportDatabase"), action: openDatabaseExport, icon: Upload });
if (canOpenDataDictionary.value) {
items.push({ label: t("dataDictionary.title"), action: openDataDictionary, icon: FileText });
}
const destructiveActions: ContextMenuItem[] = [];
if (canDropDatabase.value) {
destructiveActions.push({
@@ -6270,6 +6280,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push(exportDataSubmenu());
items.push({ label: t("contextMenu.exportDatabase"), action: openDatabaseExport, icon: Upload });
items.push({ label: t("contextMenu.exportStructure"), action: exportStructure, icon: FileCode });
if (canOpenDataDictionary.value) {
items.push({ label: t("dataDictionary.title"), action: openDataDictionary, icon: FileText });
}
items.push(copyStructureAsSubmenu());
if (isTableNotView.value) {
items.push({ label: "", separator: true });
@@ -8,6 +8,7 @@ function setup(node: Partial<TreeNode>, options: { treeNodes?: TreeNode[]; selec
const activeNode = shallowRef({ id: "n-1", label: "node", children: [], ...node } as TreeNode);
const connectionStore = {
docsSource: null as unknown,
dataDictionarySource: null as unknown,
diagramSource: null as unknown,
databaseExportSource: null as unknown,
mongoImportSource: undefined as unknown,
@@ -27,6 +28,53 @@ function setup(node: Partial<TreeNode>, options: { treeNodes?: TreeNode[]; selec
return { connectionStore, runtime };
}
describe("useSidebarTreeToolRuntime openDataDictionary", () => {
it("documents every schema when invoked on a database node", () => {
const { connectionStore, runtime } = setup({ type: "database", label: "shop", connectionId: "conn-1", database: "shop" });
runtime.openDataDictionary();
expect(connectionStore.dataDictionarySource).toEqual({ connectionId: "conn-1", database: "shop", schema: undefined });
});
it("narrows to a schema node", () => {
const { connectionStore, runtime } = setup({ type: "schema", label: "public", connectionId: "conn-1", database: "shop", schema: "public" });
runtime.openDataDictionary();
expect(connectionStore.dataDictionarySource).toEqual({ connectionId: "conn-1", database: "shop", schema: "public" });
});
it("exports the selected same-schema tables and views", () => {
const publicUsers: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
const publicOrders: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
const publicView: TreeNode = { id: "v1", label: "active_users", type: "view", connectionId: "c1", database: "db", schema: "public" };
const salesUsers: TreeNode = { id: "t3", label: "users", type: "table", connectionId: "c1", database: "db", schema: "sales" };
const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [publicUsers, publicOrders, publicView, salesUsers] };
const { connectionStore, runtime } = setup(publicUsers, {
treeNodes: [group],
selectedTreeNodeIds: [publicOrders.id, publicUsers.id, publicView.id, salesUsers.id],
});
runtime.openDataDictionary();
expect(connectionStore.dataDictionarySource).toEqual({
connectionId: "c1",
database: "db",
schema: "public",
tableNames: ["users", "orders", "active_users"],
});
});
it("does nothing when the node carries no database", () => {
const { connectionStore, runtime } = setup({ type: "connection", label: "local", connectionId: "conn-1" });
runtime.openDataDictionary();
expect(connectionStore.dataDictionarySource).toBeNull();
});
});
describe("useSidebarTreeToolRuntime openDocs", () => {
it("documents the whole database when invoked on a database node", () => {
const { connectionStore, runtime } = setup({ type: "database", label: "shop", connectionId: "conn-1", database: "shop" });
@@ -14,6 +14,7 @@ const showDataCompareDialog = ref(false);
const showSqlFileDialog = ref(false);
const showDiagramDialog = ref(false);
const showDocsDialog = ref(false);
const showDataDictionaryDialog = ref(false);
const showTableImportDialog = ref(false);
const showMongoImportDialog = ref(false);
const showMongoDatabaseDumpDialog = ref(false);
@@ -71,6 +72,10 @@ const diagramFocusTableNames = ref<string[]>([]);
const docsPrefillConnectionId = ref("");
const docsPrefillDatabase = ref("");
const docsPrefillSchema = ref("");
const dataDictionaryPrefillConnectionId = ref("");
const dataDictionaryPrefillDatabase = ref("");
const dataDictionaryPrefillSchema = ref("");
const dataDictionaryPrefillTableNames = ref<string[]>([]);
const tableImportPrefillConnectionId = ref("");
const tableImportPrefillDatabase = ref("");
const tableImportPrefillSchema = ref("");
@@ -246,6 +251,20 @@ export function useDialogSources() {
},
);
watch(
() => connectionStore.dataDictionarySource,
(v) => {
if (v) {
dataDictionaryPrefillConnectionId.value = v.connectionId;
dataDictionaryPrefillDatabase.value = v.database;
dataDictionaryPrefillSchema.value = v.schema ?? "";
dataDictionaryPrefillTableNames.value = v.tableNames ?? [];
showDataDictionaryDialog.value = true;
connectionStore.dataDictionarySource = null;
}
},
);
watch(
() => connectionStore.tableImportSource,
(v) => {
@@ -539,6 +558,7 @@ export function useDialogSources() {
showSqlFileDialog,
showDiagramDialog,
showDocsDialog,
showDataDictionaryDialog,
showTableImportDialog,
showMongoImportDialog,
showMongoDatabaseDumpDialog,
@@ -592,6 +612,10 @@ export function useDialogSources() {
docsPrefillConnectionId,
docsPrefillDatabase,
docsPrefillSchema,
dataDictionaryPrefillConnectionId,
dataDictionaryPrefillDatabase,
dataDictionaryPrefillSchema,
dataDictionaryPrefillTableNames,
tableImportPrefillConnectionId,
tableImportPrefillDatabase,
tableImportPrefillSchema,
@@ -95,6 +95,28 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
};
}
function openDataDictionary() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
if (node.type === "table" || node.type === "view" || node.type === "materialized_view") {
const targets = selectedSameSchemaStructureTargets().filter((target) => target.type === "table" || target.type === "view" || target.type === "materialized_view");
const tableNames = targets.length > 1 ? targets.map((target) => target.label) : [node.label];
connectionStore.dataDictionarySource = {
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
tableNames,
};
return;
}
connectionStore.dataDictionarySource = {
connectionId: node.connectionId,
database: node.database,
// A database node has no schema, so the collector documents every schema.
schema: node.type === "schema" ? node.schema : undefined,
};
}
function openDatabaseSearch() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
@@ -193,6 +215,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
openDataCompare,
openDatabaseExport,
openDatabaseSearch,
openDataDictionary,
openDiagram,
openDocs,
openFieldLineage,
+2
View File
@@ -1,5 +1,6 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/az";
import { dataDictionaryAz as dataDictionary } from "./dataDictionaryMessages";
import { consulAzMessages } from "./consulAz";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
import { meilisearchManagementAz } from "./meilisearchManagement";
@@ -176,6 +177,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
auth: {
rateLimited: "{seconds} saniyə sonra yenidən cəhd edin",
setupTitle: "Giriş parolunu təyin et",
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1,5 +1,6 @@
import { redisGroupingEn as redisGrouping } from "./redisGrouping";
import docs from "./docs/en";
import { dataDictionaryEn as dataDictionary } from "./dataDictionaryMessages";
import { consulUiMessages } from "./consulUi";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
import { meilisearchManagementEn } from "./meilisearchManagement";
@@ -165,6 +166,7 @@ export default {
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "Beside Plugin Center (dropdown)",
shortcutsToolbar: "Before Update (floating)",
+2
View File
@@ -3,6 +3,7 @@ import { mongodbDatabaseDumpEs as mongoDump } from "./mongodbDatabaseDump";
import { meilisearchManagementEs } from "./meilisearchManagement";
import { redisGroupingEs as redisGrouping } from "./redisGrouping";
import docs from "./docs/es";
import { dataDictionaryEs as dataDictionary } from "./dataDictionaryMessages";
import { consulEsMessages } from "./consulEs";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -168,6 +169,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "Junto al centro de plugins (desplegable)",
shortcutsToolbar: "Antes de Actualizar (flotante)",
+2
View File
@@ -3,6 +3,7 @@ import { mongodbDatabaseDumpIt as mongoDump } from "./mongodbDatabaseDump";
import { meilisearchManagementIt } from "./meilisearchManagement";
import { redisGroupingIt as redisGrouping } from "./redisGrouping";
import docs from "./docs/it";
import { dataDictionaryIt as dataDictionary } from "./dataDictionaryMessages";
import { consulItMessages } from "./consulIt";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -167,6 +168,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "Accanto al centro plugin (menu a discesa)",
shortcutsToolbar: "Prima di Aggiorna (flottante)",
+2
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import { mongodbDatabaseDumpJa as mongoDump } from "./mongodbDatabaseDump";
import docs from "./docs/ja";
import { dataDictionaryJa as dataDictionary } from "./dataDictionaryMessages";
import { consulJaMessages } from "./consulJa";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -168,6 +169,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "プラグインセンターの右側(ドロップダウン)",
shortcutsToolbar: "更新ボタンの左側(フローティング)",
+2
View File
@@ -3,6 +3,7 @@ import { mongodbDatabaseDumpKo as mongoDump } from "./mongodbDatabaseDump";
import { meilisearchManagementKo } from "./meilisearchManagement";
import { redisGroupingKo as redisGrouping } from "./redisGrouping";
import docs from "./docs/ko";
import { dataDictionaryKo as dataDictionary } from "./dataDictionaryMessages";
import { consulKoMessages } from "./consulKo";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -167,6 +168,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "플러그인 센터 오른쪽 (드롭다운)",
shortcutsToolbar: "업데이트 버튼 왼쪽 (플로팅)",
+2
View File
@@ -3,6 +3,7 @@ import { mongodbDatabaseDumpPtBR as mongoDump } from "./mongodbDatabaseDump";
import { meilisearchManagementPtBR } from "./meilisearchManagement";
import { redisGroupingPtBR as redisGrouping } from "./redisGrouping";
import docs from "./docs/pt-BR";
import { dataDictionaryPtBR as dataDictionary } from "./dataDictionaryMessages";
import { consulPtBRMessages } from "./consulPtBR";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -168,6 +169,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "Ao lado da central de plugins (menu suspenso)",
shortcutsToolbar: "Antes de Atualizar (flutuante)",
+2
View File
@@ -1,10 +1,12 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/ru";
import { dataDictionaryRu as dataDictionary } from "./dataDictionaryMessages";
import { meilisearchManagementRu } from "./meilisearchManagement";
// Russian locale. Entries omitted here fall back to English at runtime.
export default withEnglishFallback({
docs,
dataDictionary,
cellTransform: {
title: "Предпросмотр преобразования",
description: "Преобразуйте текущее значение для просмотра или копирования. Исходное значение и черновик редактора не изменяются.",
+2
View File
@@ -1,5 +1,6 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/tr";
import { dataDictionaryTr as dataDictionary } from "./dataDictionaryMessages";
import { consulTrMessages } from "./consulTr";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
import { meilisearchManagementTr } from "./meilisearchManagement";
@@ -178,6 +179,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
auth: {
rateLimited: "Lütfen {seconds} sn sonra tekrar deneyin",
setupTitle: "Erişim parolası oluştur",
+2
View File
@@ -1,6 +1,7 @@
import { redisGroupingZhCN as redisGrouping } from "./redisGrouping";
import { withEnglishFallback } from "./fallback";
import docs from "./docs/zh-CN";
import { dataDictionaryZhCN as dataDictionary } from "./dataDictionaryMessages";
import { consul } from "./consulZhCN";
import { meilisearchManagementZhCN } from "./meilisearchManagement";
import { mongodbDatabaseDumpZhCN as mongoDump } from "./mongodbDatabaseDump";
@@ -90,6 +91,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "插件中心右侧(下拉)",
shortcutsToolbar: "更新按钮左侧(浮动)",
+2
View File
@@ -3,6 +3,7 @@ import { withEnglishFallback } from "./fallback";
import { mongodbDatabaseDumpZhTW as mongoDump } from "./mongodbDatabaseDump";
import { meilisearchManagementZhTW } from "./meilisearchManagement";
import docs from "./docs/zh-TW";
import { dataDictionaryZhTW as dataDictionary } from "./dataDictionaryMessages";
import { consulZhTWMessages } from "./consulZhTW";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -168,6 +169,7 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
dataDictionary,
pluginPlatform: {
shortcutsPluginCenter: "外掛中心右側(下拉)",
shortcutsToolbar: "更新按鈕左側(浮動)",
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { supportsDataDictionary, supportsSchemaDiagram } from "@/lib/database/databaseFeatureSupport";
describe("supportsDataDictionary", () => {
it("is available for relational engines, including ones without a diagram", () => {
expect(supportsDataDictionary("mysql")).toBe(true);
expect(supportsDataDictionary("oracle")).toBe(true);
expect(supportsDataDictionary("postgres")).toBe(true);
expect(supportsDataDictionary("doris")).toBe(true);
expect(supportsSchemaDiagram("doris")).toBe(false);
});
it("is hidden for engines that cannot list table metadata", () => {
expect(supportsDataDictionary("redis")).toBe(false);
expect(supportsDataDictionary("plugin")).toBe(false);
});
});
@@ -198,6 +198,11 @@ export function supportsSchemaDiagram(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "diagram");
}
/** Relational engines that can list tables and columns. Independent of diagram support. */
export function supportsDataDictionary(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "metadataBrowse");
}
export function supportsDatabaseSearch(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "schemaSearch");
}
@@ -0,0 +1,280 @@
import { describe, expect, it } from "vitest";
import type { ColumnInfo } from "@/docs/types";
import { applyTemplate, dataDictionaryFileName, dictionaryCatalogNames, exportBlockedBySkippedTables, moveOrderedKey, objectKey, orderDictionaryTables, resolveDictionaryExportPath, toggleOrderedKey, warningsForSelection, type DictionaryLayout, type DictionaryTable } from "@/lib/docs/dataDictionary";
import { buildDataDictionaryPdf, pdfUtf16Hex } from "@/lib/docs/dataDictionaryPdf";
const labels = {
column: "Column",
type: "Type",
length: "Length",
precision: "Precision",
scale: "Scale",
primaryKey: "Primary key",
nullable: "Nullable",
unique: "Unique",
defaultValue: "Default",
extra: "Extra",
comment: "Comment",
indexName: "Index",
indexColumns: "Index columns",
indexType: "Index type",
constraintName: "Constraint",
refSchema: "Referenced schema",
refTable: "Referenced table",
refColumn: "Referenced column",
onUpdate: "ON UPDATE",
onDelete: "ON DELETE",
yes: "Yes",
no: "No",
indexesHeading: "Indexes",
foreignKeysHeading: "Foreign keys",
contentsHeading: "Contents",
kindTable: "Table",
kindView: "View",
kindMaterializedView: "Materialized view",
};
function column(partial: Partial<ColumnInfo> & Pick<ColumnInfo, "name" | "data_type">): ColumnInfo {
return {
is_nullable: true,
column_default: null,
is_primary_key: false,
extra: null,
comment: null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
...partial,
};
}
function table(partial: Partial<DictionaryTable> & Pick<DictionaryTable, "name">): DictionaryTable {
return {
database: "shop",
schema: "public",
kind: "TABLE",
columns: [column({ name: "id", data_type: "integer", is_primary_key: true, is_nullable: false })],
indexes: [],
foreignKeys: [],
groupId: null,
note: null,
noteSource: "NONE",
shadowedNote: null,
columnNotes: {},
estimatedRows: null,
viewDefinition: null,
...partial,
};
}
describe("dictionaryCatalogNames", () => {
it("hides internal schemas unless that schema was explicitly opened", () => {
const listed = ["shop", "information_schema", "performance_schema", "mysql", "sys", "public"];
expect(dictionaryCatalogNames(listed, "mysql")).toEqual(["shop", "public"]);
expect(dictionaryCatalogNames(listed, "mysql", "information_schema")).toEqual(["shop", "information_schema", "public"]);
expect(dictionaryCatalogNames(["public", "pg_catalog", "information_schema"], "postgres")).toEqual(["public"]);
});
});
describe("dictionary selection", () => {
it("checks, unchecks, and reorders objects", () => {
const orders = objectKey({ database: "shop", schema: "public", name: "orders" });
const users = objectKey({ database: "shop", schema: "public", name: "users" });
expect(toggleOrderedKey([], orders, true)).toEqual([orders]);
expect(toggleOrderedKey([orders, users], orders, false)).toEqual([users]);
expect(moveOrderedKey([orders, users], users, -1)).toEqual([users, orders]);
});
it("exports tables in the selected order", () => {
const orders = table({ name: "orders" });
const users = table({ name: "users" });
const ordered = orderDictionaryTables([orders, users], [objectKey(users), objectKey(orders)]);
expect(ordered.map((item) => item.name)).toEqual(["users", "orders"]);
});
});
describe("dataDictionaryFileName", () => {
it("appends a timestamp when requested, including on a path that was already chosen", () => {
const now = new Date("2026-09-24T14:05:06");
expect(dataDictionaryFileName("shop", false, now)).toBe("shop-data-dictionary.pdf");
expect(dataDictionaryFileName("a/b", true, now)).toBe("a_b-data-dictionary_20260924140506.pdf");
expect(resolveDictionaryExportPath("/tmp/shop.pdf", "shop-data-dictionary.pdf", true, now)).toBe("/tmp/shop_20260924140506.pdf");
expect(resolveDictionaryExportPath("", "shop-data-dictionary.pdf", true, now)).toBe("shop-data-dictionary_20260924140506.pdf");
expect(resolveDictionaryExportPath("/tmp/shop_20260924140506.pdf", "ignored.pdf", true, now)).toBe("/tmp/shop_20260924140506.pdf");
});
});
describe("exportBlockedBySkippedTables", () => {
it("stops export only when continue-on-error is off and a selected table was skipped", () => {
const warnings = [{ kind: "tableSkipped", table: "secret", reason: "denied" }, { kind: "tableSkipped", table: "public.secret", reason: "denied" }, { kind: "commentsUnsupported" }];
const selected = [{ schema: "public", name: "users" }];
const relevant = warningsForSelection(warnings, selected);
expect(exportBlockedBySkippedTables(relevant, false)).toBe(false);
expect(exportBlockedBySkippedTables(relevant, true)).toBe(false);
expect(relevant.some((warning) => warning.table?.includes("secret"))).toBe(false);
expect(exportBlockedBySkippedTables(warningsForSelection([{ kind: "tableSkipped", table: "public.users", reason: "denied" }], selected), false)).toBe(true);
});
it("keeps the collector's empty-schema skip and drops schema-wide skips for other schemas", () => {
const selected = [{ schema: "", name: "orders" }];
const kept = warningsForSelection([{ kind: "tableSkipped", table: ".orders", reason: "denied" }], selected);
const otherSchema = warningsForSelection([{ kind: "tableSkipped", table: "analytics.*", reason: "denied" }], selected);
const ownSchema = warningsForSelection([{ kind: "tableSkipped", table: ".*", reason: "denied" }], selected);
expect(exportBlockedBySkippedTables(kept, false)).toBe(true);
expect(kept.map((warning) => warning.table)).toEqual([".orders"]);
expect(exportBlockedBySkippedTables(otherSchema, false)).toBe(false);
expect(otherSchema).toEqual([]);
expect(ownSchema.map((warning) => warning.table)).toEqual([".*"]);
});
});
describe("applyTemplate", () => {
it("changes paper and sections with the template", () => {
const standard = applyTemplate("standard", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" });
const compact = applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" });
expect(standard.includeCover).toBe(true);
expect(standard.includeIndexesAndForeignKeys).toBe(true);
expect(compact.includeCover).toBe(false);
expect(compact.includeIndexesAndForeignKeys).toBe(false);
expect(compact.orientation).toBe("landscape");
});
});
function pdfText(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
function textAt(pdf: string, text: string): number {
const cjk = [...text].filter((char) => (char.codePointAt(0) ?? 0) >= 128).join("");
if (cjk && [...text].every((char) => (char.codePointAt(0) ?? 0) >= 128)) return pdf.indexOf(pdfUtf16Hex(cjk));
const literal = pdf.indexOf(`(${text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")})`);
if (literal >= 0) return literal;
const raw = pdf.indexOf(text);
return raw >= 0 && pdf[raw - 1] !== "/" ? raw : -1;
}
describe("buildDataDictionaryPdf", () => {
it("writes a PDF whose text includes only the chosen tables in order and omits indexes when that choice is off", () => {
const users = table({ name: "users" });
const orders = table({
name: "orders",
note: "订单",
indexes: [{ name: "orders_pkey", columns: ["id"], is_unique: true, is_primary: true, filter: null, index_type: "btree", included_columns: null, comment: null }],
});
const guests = table({ name: "guests" });
const chosen = orderDictionaryTables([users, orders, guests], [objectKey(orders), objectKey(users)]);
const layout = applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" });
const pdf = pdfText(buildDataDictionaryPdf(chosen, labels, layout));
expect(pdf.startsWith("%PDF-1.4")).toBe(true);
const ordersAt = textAt(pdf, "orders");
const usersAt = textAt(pdf, "users");
expect(ordersAt).toBeGreaterThan(-1);
expect(usersAt).toBeGreaterThan(ordersAt);
expect(textAt(pdf, "订单")).toBeGreaterThan(-1);
expect(textAt(pdf, "orders_pkey")).toBe(-1);
expect(textAt(pdf, "guests")).toBe(-1);
});
it("changes the file when cover, contents, introduction, indexes, or paper changes", () => {
const orders = table({
name: "orders",
indexes: [{ name: "orders_pkey", columns: ["id"], is_unique: true, is_primary: true, filter: null, index_type: "btree", included_columns: null, comment: null }],
});
const base: DictionaryLayout = {
...applyTemplate("standard", { title: "shop", introduction: "INTRO-ONLY-TEXT", detailedIntroduction: "detail", leftFooter: "shop" }),
subtitle: "SUBTITLE-ONLY",
includeIndexesAndForeignKeys: true,
};
const withChoices = pdfText(buildDataDictionaryPdf([orders], labels, base));
const withoutCover = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeCover: false }));
const withoutContents = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeToc: false }));
const withoutIntro = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeIntroduction: false }));
const withoutIndexes = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeIndexesAndForeignKeys: false }));
const landscape = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, orientation: "landscape" }));
expect(textAt(withChoices, "SUBTITLE-ONLY")).toBeGreaterThan(-1);
expect(textAt(withoutCover, "SUBTITLE-ONLY")).toBe(-1);
expect(textAt(withChoices, labels.contentsHeading)).toBeGreaterThan(-1);
expect(textAt(withoutContents, labels.contentsHeading)).toBe(-1);
expect(textAt(withChoices, "INTRO-ONLY-TEXT")).toBeGreaterThan(-1);
expect(textAt(withoutIntro, "INTRO-ONLY-TEXT")).toBe(-1);
expect(textAt(withChoices, "orders_pkey")).toBeGreaterThan(-1);
expect(textAt(withoutIndexes, "orders_pkey")).toBe(-1);
expect(withChoices).toContain("/MediaBox [0 0 595.28 841.89]");
expect(landscape).toContain("/MediaBox [0 0 841.89 595.28]");
});
it("numbers pages from the introduction so TOC entries match the printed footers", () => {
const orders = table({ name: "orders" });
const layout = applyTemplate("standard", {
title: "shop",
introduction: Array.from({ length: 160 }, (_, index) => `INTRO-LINE-${index}`).join("\n"),
detailedIntroduction: "detail",
leftFooter: "shop",
});
const streams = pdfText(buildDataDictionaryPdf([orders], labels, layout))
.split("endstream")
.filter((chunk) => chunk.includes("BT"));
const introStreams = streams.filter((chunk) => chunk.includes("INTRO-LINE-"));
expect(introStreams.length).toBeGreaterThanOrEqual(2);
const lastIntroIndex = streams.lastIndexOf(introStreams[introStreams.length - 1]!);
const bodyStream = streams.slice(lastIntroIndex + 1).find((chunk) => chunk.includes("orders"));
expect(bodyStream).toBeDefined();
const tocStream = streams.find((chunk) => chunk.includes("Contents"));
expect(tocStream).toBeDefined();
const introPages = introStreams.length;
expect(introStreams[0]).toContain("(1) Tj");
expect(introStreams[introPages - 1]).toContain(`(${introPages}) Tj`);
expect(bodyStream).toContain(`(${introPages + 1}) Tj`);
expect(tocStream).toContain(`(${introPages + 1}) Tj`);
});
it("does not abort or name an unselected skipped table when the snapshot warned about it", () => {
const users = table({ name: "users", schema: "public" });
const orders = table({ name: "orders", schema: "public" });
const selected = orderDictionaryTables([users, orders], [objectKey(users)]);
const warnings = warningsForSelection(
[
{ kind: "tableSkipped", table: "secret", reason: "denied" },
{ kind: "tableSkipped", table: "public.secret", reason: "denied" },
],
selected,
);
expect(exportBlockedBySkippedTables(warnings, false)).toBe(false);
const pdf = pdfText(
buildDataDictionaryPdf(
selected,
labels,
applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" }),
warnings.map((warning) => `${warning.table}: ${warning.reason}`),
),
);
expect(textAt(pdf, "users")).toBeGreaterThan(-1);
expect(textAt(pdf, "secret")).toBe(-1);
expect(textAt(pdf, "orders")).toBe(-1);
});
it("names a selected empty-schema skip in the PDF and omits another schema's skip", () => {
const orders = table({ name: "orders", schema: "" });
const selected = [{ schema: "", name: "orders" }];
const warnings = warningsForSelection(
[
{ kind: "tableSkipped", table: ".orders", reason: "denied" },
{ kind: "tableSkipped", table: "analytics.*", reason: "denied" },
],
selected,
);
const pdf = pdfText(
buildDataDictionaryPdf(
[orders],
labels,
applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" }),
warnings.map((warning) => `${warning.table}: ${warning.reason}`),
),
);
expect(textAt(pdf, ".orders")).toBeGreaterThan(-1);
expect(textAt(pdf, "analytics.*")).toBe(-1);
});
});
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveDictionaryExportPath } from "@/lib/docs/dataDictionary";
describe("saveDataDictionaryFile", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("@/lib/backend/tauriRuntime");
vi.doUnmock("@tauri-apps/plugin-dialog");
vi.doUnmock("@tauri-apps/plugin-fs");
});
async function loadSaver(save: ReturnType<typeof vi.fn>, writeFile: ReturnType<typeof vi.fn>, exists: ReturnType<typeof vi.fn>) {
vi.resetModules();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.doMock("@tauri-apps/plugin-dialog", () => ({ save }));
vi.doMock("@tauri-apps/plugin-fs", () => ({ writeFile, exists }));
return import("../saveDataDictionaryFile");
}
it("writes only the path the save dialog just returned, after stamping the suggested name", async () => {
const save = vi.fn().mockResolvedValue("/granted/shop.pdf");
const writeFile = vi.fn().mockResolvedValue(undefined);
const exists = vi.fn().mockResolvedValue(false);
const { saveDataDictionaryFile } = await loadSaver(save, writeFile, exists);
const suggested = resolveDictionaryExportPath("/typed/shop.pdf", "shop-data-dictionary.pdf", true, new Date("2026-09-24T14:05:06"));
const bytes = new Uint8Array([37, 80, 68, 70]);
await saveDataDictionaryFile(suggested, bytes, { overwrite: true });
expect(save).toHaveBeenCalledWith({
defaultPath: "/typed/shop_20260924140506.pdf",
filters: [{ name: "PDF", extensions: ["pdf"] }],
});
expect(writeFile).toHaveBeenCalledTimes(1);
expect(writeFile.mock.calls[0]?.[0]).toBe("/granted/shop.pdf");
expect(writeFile.mock.calls[0]?.[0]).not.toBe(suggested);
expect(exists).not.toHaveBeenCalled();
});
it("refuses to write a granted path that already exists when overwrite is off", async () => {
const save = vi.fn().mockResolvedValue("/granted/shop.pdf");
const writeFile = vi.fn().mockResolvedValue(undefined);
const exists = vi.fn().mockResolvedValue(true);
const { saveDataDictionaryFile, DictionaryFileExistsError } = await loadSaver(save, writeFile, exists);
await expect(saveDataDictionaryFile("shop-data-dictionary.pdf", new Uint8Array([1]), { overwrite: false })).rejects.toBeInstanceOf(DictionaryFileExistsError);
expect(writeFile).not.toHaveBeenCalled();
});
});
+236
View File
@@ -0,0 +1,236 @@
import type { ColumnInfo, DocTable, TableKind } from "@/docs/types";
import type { DatabaseType } from "@/types/database";
import { isSystemDatabaseName, isSystemSchemaName } from "@/lib/database/visibleDatabases";
export type DictionaryPaper = "A4" | "A3" | "Letter" | "Legal";
export type DictionaryOrientation = "portrait" | "landscape";
export type DictionaryTemplateId = "standard" | "detailed" | "compact" | "catalog" | "reference";
export interface DataDictionaryLabels {
column: string;
type: string;
length: string;
precision: string;
scale: string;
primaryKey: string;
nullable: string;
unique: string;
defaultValue: string;
extra: string;
comment: string;
indexName: string;
indexColumns: string;
indexType: string;
constraintName: string;
refSchema: string;
refTable: string;
refColumn: string;
onUpdate: string;
onDelete: string;
yes: string;
no: string;
indexesHeading: string;
foreignKeysHeading: string;
contentsHeading: string;
kindTable: string;
kindView: string;
kindMaterializedView: string;
}
export interface DictionaryLayout {
includeCover: boolean;
header: string;
title: string;
subtitle: string;
remarks: string;
coverFooter: string;
includeToc: boolean;
includeBreadcrumbs: boolean;
includeLeftFooter: boolean;
leftFooter: string;
includePageNumber: boolean;
includeIntroduction: boolean;
introduction: string;
paper: DictionaryPaper;
orientation: DictionaryOrientation;
marginCm: number;
headingSize: number;
bodySize: number;
includeIndexesAndForeignKeys: boolean;
}
export interface DictionaryObjectRef {
database: string;
schema: string;
name: string;
kind: TableKind;
}
export interface DictionaryProfile {
version: 1;
databases: string[];
schemas: Array<{ database: string; name: string }>;
objects: Array<{ database: string; schema: string; name: string }>;
layout: DictionaryLayout;
appendTimestamp: boolean;
overwrite: boolean;
continueOnError: boolean;
fileName: string;
}
export interface DictionaryTable extends DocTable {
database: string;
}
const TEMPLATES: Record<DictionaryTemplateId, Omit<DictionaryLayout, "header" | "title" | "subtitle" | "remarks" | "coverFooter" | "leftFooter" | "introduction">> = {
standard: { includeCover: true, includeToc: true, includeBreadcrumbs: true, includeLeftFooter: true, includePageNumber: true, includeIntroduction: true, paper: "A4", orientation: "portrait", marginCm: 1.5, headingSize: 16, bodySize: 9, includeIndexesAndForeignKeys: true },
detailed: { includeCover: true, includeToc: true, includeBreadcrumbs: true, includeLeftFooter: true, includePageNumber: true, includeIntroduction: true, paper: "A4", orientation: "landscape", marginCm: 1.5, headingSize: 18, bodySize: 9, includeIndexesAndForeignKeys: true },
compact: { includeCover: false, includeToc: false, includeBreadcrumbs: true, includeLeftFooter: false, includePageNumber: true, includeIntroduction: false, paper: "A4", orientation: "landscape", marginCm: 1, headingSize: 14, bodySize: 8, includeIndexesAndForeignKeys: false },
catalog: { includeCover: true, includeToc: true, includeBreadcrumbs: false, includeLeftFooter: true, includePageNumber: true, includeIntroduction: false, paper: "A4", orientation: "portrait", marginCm: 1.5, headingSize: 16, bodySize: 9, includeIndexesAndForeignKeys: false },
reference: { includeCover: false, includeToc: true, includeBreadcrumbs: true, includeLeftFooter: true, includePageNumber: true, includeIntroduction: false, paper: "A3", orientation: "landscape", marginCm: 1.2, headingSize: 14, bodySize: 8, includeIndexesAndForeignKeys: true },
};
const INTERNAL_DICTIONARY_NAMES = new Set(["information_schema", "performance_schema", "mysql", "sys", "pg_catalog", "pg_toast"]);
export function isInternalDictionaryName(databaseType: DatabaseType | undefined, name: string): boolean {
const normalized = name.trim().toLowerCase();
if (!normalized) return false;
if (INTERNAL_DICTIONARY_NAMES.has(normalized) || normalized.startsWith("pg_toast") || normalized.startsWith("pg_temp_")) return true;
return isSystemDatabaseName(databaseType, name) || isSystemSchemaName(databaseType, name);
}
export function dictionaryCatalogNames(names: string[], databaseType: DatabaseType | undefined, pinned?: string): string[] {
return names.filter((name) => name === pinned || !isInternalDictionaryName(databaseType, name));
}
export function objectKey(ref: { database: string; schema: string; name: string }): string {
return `${ref.database}\u0000${ref.schema}\u0000${ref.name}`;
}
export function tableKindFrom(tableType: string): TableKind {
const normalized = tableType.trim().toUpperCase().replaceAll("_", " ");
if (normalized.includes("MATERIALIZED")) return "MATERIALIZED_VIEW";
if (normalized.includes("VIEW")) return "VIEW";
return "TABLE";
}
export function applyTemplate(id: DictionaryTemplateId, context: { title: string; introduction: string; detailedIntroduction: string; leftFooter: string }): DictionaryLayout {
const preset = TEMPLATES[id];
return {
...preset,
header: context.title,
title: context.title,
subtitle: "",
remarks: "",
coverFooter: "",
leftFooter: context.leftFooter,
introduction: id === "detailed" ? context.detailedIntroduction : context.introduction,
};
}
export function toggleOrderedKey(order: string[], key: string, checked: boolean): string[] {
const without = order.filter((item) => item !== key);
return checked ? [...without, key] : without;
}
export function moveOrderedKey(order: string[], key: string, direction: -1 | 1): string[] {
const index = order.indexOf(key);
const next = index + direction;
if (index < 0 || next < 0 || next >= order.length) return order;
const copy = order.slice();
const [item] = copy.splice(index, 1);
copy.splice(next, 0, item!);
return copy;
}
export function orderDictionaryTables(tables: DictionaryTable[], keys: string[]): DictionaryTable[] {
const byKey = new Map(tables.map((table) => [objectKey({ database: table.database, schema: table.schema ?? "", name: table.name }), table]));
return keys.flatMap((key) => {
const table = byKey.get(key);
return table ? [table] : [];
});
}
export function dictionaryTimestamp(now = new Date()): string {
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
}
export function appendTimestampToFileName(path: string, now = new Date()): string {
const stamp = dictionaryTimestamp(now);
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
const dir = slash >= 0 ? path.slice(0, slash + 1) : "";
const base = slash >= 0 ? path.slice(slash + 1) : path;
const extIndex = base.toLowerCase().endsWith(".pdf") ? base.length - 4 : -1;
const stem = extIndex >= 0 ? base.slice(0, extIndex) : base;
const ext = extIndex >= 0 ? base.slice(extIndex) : ".pdf";
if (/_\d{14}$/.test(stem)) return `${dir}${stem}${ext}`;
return `${dir}${stem}_${stamp}${ext}`;
}
export function dataDictionaryFileName(database: string, appendTimestamp: boolean, now = new Date()): string {
const safe = database.replace(/[\\/:*?"<>|]/g, "_").trim() || "DataDictionary";
const name = `${safe}-data-dictionary.pdf`;
return appendTimestamp ? appendTimestampToFileName(name, now) : name;
}
/** Apply the timestamp choice to either a typed/browsed path or the fallback name. */
export function resolveDictionaryExportPath(existingPath: string, fallbackName: string, appendTimestamp: boolean, now = new Date()): string {
const chosen = existingPath.trim();
const name = chosen || fallbackName;
return appendTimestamp ? appendTimestampToFileName(name, now) : name;
}
export function exportBlockedBySkippedTables(warnings: Array<{ kind: string }>, continueOnError: boolean): boolean {
return !continueOnError && warnings.some((warning) => warning.kind === "tableSkipped");
}
function warningNamesSelectedTable(table: string, schema: string, name: string): boolean {
if (table === name) return true;
// Collector formats skips as `{schema}.{table}`, so an empty schema is `.orders`.
return table === `${schema}.${name}`;
}
function schemaWideSkipMatches(table: string, selected: ReadonlyArray<{ schema?: string | null }>): boolean {
if (table === "*") return true;
if (!table.endsWith(".*")) return false;
const schema = table.slice(0, -2);
return selected.some((item) => (item.schema ?? "") === schema);
}
/** Drop skip warnings that do not name a selected object or that object's schema. */
export function warningsForSelection<T extends { kind: string; table?: string }>(warnings: T[], selected: ReadonlyArray<{ schema?: string | null; name: string }>): T[] {
return warnings.filter((warning) => {
const table = warning.table;
if (!table) return true;
if (table === "*" || table.endsWith(".*")) return schemaWideSkipMatches(table, selected);
return selected.some((item) => warningNamesSelectedTable(table, item.schema ?? "", item.name));
});
}
export function isDictionaryProfile(value: unknown): value is DictionaryProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<DictionaryProfile>;
return profile.version === 1 && Array.isArray(profile.databases) && Array.isArray(profile.objects) && !!profile.layout && typeof profile.fileName === "string";
}
export function columnComment(table: DocTable, column: ColumnInfo): string {
const note = table.columnNotes[column.name]?.note;
if (note && note.trim() !== "") return note;
return column.comment ?? "";
}
export function typeLabel(column: ColumnInfo): string {
const base = column.data_type.trim();
if (base.includes("(")) return base;
if (column.character_maximum_length !== null) return `${base}(${column.character_maximum_length})`;
if (column.numeric_precision !== null) {
const scale = column.numeric_scale === null ? "" : `,${column.numeric_scale}`;
return `${base}(${column.numeric_precision}${scale})`;
}
return base;
}
export function qualifiedObjectName(table: Pick<DictionaryTable, "database" | "schema" | "name">): string {
const schema = table.schema ? `${table.schema}.` : "";
return table.database ? `${table.database}.${schema}${table.name}` : `${schema}${table.name}`;
}
@@ -0,0 +1,482 @@
import type { DataDictionaryLabels, DictionaryLayout, DictionaryTable } from "@/lib/docs/dataDictionary";
import { columnComment, qualifiedObjectName, typeLabel } from "@/lib/docs/dataDictionary";
const CM = 28.3464567;
const INK = "0.16 0.16 0.16";
const MUTED = "0.38 0.38 0.38";
const ORANGE = "0.91 0.44 0.08";
const BROWN = "0.35 0.18 0.06";
const PEACH = "0.99 0.88 0.74";
const GRID = "0.90 0.50 0.18";
const CHECK = "\u0001";
const PAPER: Record<DictionaryLayout["paper"], { width: number; height: number }> = {
A4: { width: 595.28, height: 841.89 },
A3: { width: 841.89, height: 1190.55 },
Letter: { width: 612, height: 792 },
Legal: { width: 612, height: 1008 },
};
interface Page {
commands: string[];
bookmark?: string;
}
interface PlacedObject {
table: DictionaryTable;
page: number;
}
interface TocEntry {
label: string;
page: number;
indent: number;
}
export function pdfUtf16Hex(text: string): string {
let hex = "";
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code > 0xffff) {
const adjusted = code - 0x10000;
hex += (0xd800 + (adjusted >> 10)).toString(16).padStart(4, "0");
hex += (0xdc00 + (adjusted & 0x3ff)).toString(16).padStart(4, "0");
} else {
hex += code.toString(16).padStart(4, "0");
}
}
return hex.toUpperCase();
}
export function buildDataDictionaryPdf(tables: DictionaryTable[], labels: DataDictionaryLabels, layout: DictionaryLayout, warnings: string[] = []): Uint8Array {
const page = pageSize(layout);
const margin = clamp(layout.marginCm, 0.5, 4) * CM;
const includeIntro = layout.includeIntroduction && layout.introduction.trim() !== "";
const intro = includeIntro ? flowLines(layout.introduction.split(/\n+/), page, margin, layout.bodySize, introHeading(labels)) : [];
const body = flowObjects(tables, labels, layout, page, margin);
const notes = warnings.length ? flowLines(warnings, page, margin, layout.bodySize) : [];
const toc = layout.includeToc ? renderToc(tocFor(labels, tables, body.placed, intro.length), labels, page, margin, layout.bodySize) : [];
const cover = layout.includeCover ? [coverPage(layout, page, margin)] : [];
// Page numbers start at the introduction so TOC entries (numbered from the
// introduction) match the printed footers; cover and contents stay unnumbered.
const frontCount = cover.length + toc.length;
const pages = [...cover, ...toc, ...intro, ...body.pages, ...notes];
if (pages.length === 0) pages.push({ commands: [] });
stampChrome(pages, layout, page, margin, frontCount);
return encodePdf(pages, page, layout.title || "Data Dictionary");
}
function pageSize(layout: DictionaryLayout): { width: number; height: number } {
const paper = PAPER[layout.paper];
return layout.orientation === "landscape" ? { width: paper.height, height: paper.width } : paper;
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function charWidth(char: string, size: number): number {
const code = char.codePointAt(0) ?? 0;
if (code >= 128) return size;
if ("ilj.,:;!'|".includes(char)) return size * 0.28;
if (char === " ") return size * 0.28;
if ("mwMW@%".includes(char)) return size * 0.78;
return size * 0.52;
}
function textWidth(text: string, size: number): number {
let width = 0;
for (const char of text) width += charWidth(char, size);
return width;
}
function wrap(text: string, size: number, width: number): string[] {
const lines: string[] = [];
let line = "";
for (const char of text) {
const next = line + char;
if (line && textWidth(next, size) > width) {
lines.push(line);
line = char;
} else {
line = next;
}
}
if (line || lines.length === 0) lines.push(line);
return lines;
}
function fill(x: number, y: number, width: number, height: number, color: string): string {
return `${color} rg ${num(x)} ${num(y)} ${num(width)} ${num(height)} re f`;
}
function coverPage(layout: DictionaryLayout, page: { width: number; height: number }, margin: number): Page {
const commands: string[] = [];
let y = page.height * 0.62;
const titleSize = Math.max(28, layout.headingSize + 16);
for (const line of wrap(layout.title || "Data Dictionary", titleSize, page.width - margin * 2)) {
commands.push(textOp(margin, y, titleSize, line, ORANGE));
y -= titleSize + 6;
}
y -= 4;
const second = layout.subtitle.trim() || "数据字典";
commands.push(textOp(margin, y, layout.headingSize + 6, second, BROWN));
y -= layout.headingSize + 28;
const meta = [layout.header, layout.remarks, layout.coverFooter].filter((line) => line && line !== layout.title && line !== second);
for (const line of meta) {
commands.push(textOp(margin, y, layout.bodySize + 1, line, INK));
y -= layout.bodySize + 8;
}
coverShapes(commands, page.width, 0);
return { commands };
}
function coverShapes(commands: string[], pageWidth: number, bottom: number): void {
const shades = [ORANGE, "0.96 0.62 0.22", "0.98 0.78 0.45", "0.93 0.55 0.16"];
const tiles = [
[pageWidth * 0.08, bottom + 36, 92, 64, -18],
[pageWidth * 0.28, bottom + 18, 110, 72, -12],
[pageWidth * 0.48, bottom + 48, 120, 70, -8],
[pageWidth * 0.66, bottom + 12, 100, 68, -16],
[pageWidth * 0.82, bottom + 40, 90, 60, -10],
] as const;
tiles.forEach(([x, y, width, height, degrees], index) => {
commands.push(rotatedFill(x, y, width, height, degrees, shades[index % shades.length]!));
});
}
function rotatedFill(x: number, y: number, width: number, height: number, degrees: number, color: string): string {
const rad = (degrees * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return `q ${color} rg ${num(cos)} ${num(sin)} ${num(-sin)} ${num(cos)} ${num(x)} ${num(y)} cm 0 0 ${num(width)} ${num(height)} re f Q`;
}
function introHeading(labels: DataDictionaryLabels): string {
return labels.contentsHeading === "目录" ? "1. 引言" : "1. Introduction";
}
function tocFor(labels: DataDictionaryLabels, tables: DictionaryTable[], placed: PlacedObject[], introPages: number): TocEntry[] {
const entries: TocEntry[] = [];
let section = 1;
if (introPages > 0) {
entries.push({ label: introHeading(labels), page: 1, indent: 0 });
section = 2;
}
const seen = new Set<string>();
for (const item of placed) {
const database = item.table.database || labels.kindTable;
if (!seen.has(database)) {
seen.add(database);
entries.push({ label: `${section}. ${database}`, page: introPages + item.page + 1, indent: 0 });
section += 1;
}
entries.push({ label: `${labels.kindTable}: ${item.table.name}`, page: introPages + item.page + 1, indent: 2 });
}
if (entries.length === 0 && tables.length === 0) entries.push({ label: labels.contentsHeading, page: 1, indent: 0 });
return entries;
}
function tocLineHeight(body: number): number {
return body + 10;
}
function renderToc(entries: TocEntry[], labels: DataDictionaryLabels, page: { width: number; height: number }, margin: number, body: number): Page[] {
const pages: Page[] = [];
const line = tocLineHeight(body);
const capacity = Math.max(1, Math.floor((page.height - margin * 2 - 52) / line));
const rows = entries.length ? entries : [{ label: labels.contentsHeading, page: 1, indent: 0 }];
for (let offset = 0; offset < rows.length; offset += capacity) {
const slice = rows.slice(offset, offset + capacity);
const commands: string[] = [];
let y = page.height - margin - 8;
y = orangeHeading(commands, margin, y, page.width - margin * 2, labels.contentsHeading, 16);
y -= 8;
for (const item of slice) {
const x = margin + item.indent * 16;
const pageLabel = String(item.page);
const pageWidth = textWidth(pageLabel, body);
commands.push(textOp(x, y, body, item.label, INK));
commands.push(textOp(page.width - margin - pageWidth, y, body, pageLabel, INK));
const dotsFrom = x + textWidth(item.label, body) + 4;
const dotsTo = page.width - margin - pageWidth - 4;
for (let dot = dotsFrom; dot < dotsTo; dot += 3.2) commands.push(fill(dot, y + 1, 0.7, 0.7, "0.55 0.55 0.55"));
y -= line;
}
pages.push({ commands });
}
return pages;
}
function orangeHeading(commands: string[], x: number, y: number, width: number, text: string, size: number): number {
commands.push(textOp(x, y, size, text, ORANGE));
const textW = Math.min(textWidth(text, size), width - 24);
const lineY = y + size * 0.35;
commands.push(`${ORANGE} RG 1.4 w ${num(x + textW + 8)} ${num(lineY)} m ${num(x + width - 6)} ${num(lineY)} l S`);
commands.push(`${ORANGE} rg ${num(x + width - 3.5)} ${num(lineY)} 3.2 0 360 arc f`);
return y - size - 8;
}
function flowLines(lines: string[], page: { width: number; height: number }, margin: number, body: number, heading = ""): Page[] {
const pages: Page[] = [];
let commands: string[] = [];
let y = page.height - margin - body - 8;
const start = () => {
commands = [];
y = page.height - margin - 8;
if (heading) y = orangeHeading(commands, margin, y, page.width - margin * 2, heading, body + 5) - 6;
};
const push = () => {
if (commands.length) pages.push({ commands });
start();
};
start();
for (const paragraph of lines) {
for (const line of wrap(paragraph, body, page.width - margin * 2)) {
if (y < margin + body) push();
commands.push(textOp(margin, y, body, line, INK));
y -= body + 5;
}
y -= 8;
}
if (commands.length) pages.push({ commands });
return pages;
}
function flowObjects(tables: DictionaryTable[], labels: DataDictionaryLabels, layout: DictionaryLayout, page: { width: number; height: number }, margin: number): { pages: Page[]; placed: PlacedObject[] } {
const pages: Page[] = [];
const placed: PlacedObject[] = [];
const cursor = { commands: [] as string[], y: 0, bookmark: undefined as string | undefined };
const bottom = contentBottom(margin);
const top = () => page.height - margin - (layout.includeBreadcrumbs ? 22 : 6);
const flush = () => {
if (cursor.commands.length === 0 && !cursor.bookmark) return;
pages.push({ commands: cursor.commands, bookmark: cursor.bookmark });
cursor.commands = [];
cursor.bookmark = undefined;
cursor.y = top();
};
const nextPage = () => flush();
const ensure = (height: number) => {
if (cursor.y - height < bottom) nextPage();
};
cursor.y = top();
if (tables.length === 0) return { pages: [{ commands: [] }], placed };
for (const table of tables) {
const title = `${kindLabel(table, labels)}: ${table.name}`;
ensure(layout.headingSize + 20);
if (!cursor.bookmark) cursor.bookmark = qualifiedObjectName(table);
placed.push({ table, page: pages.length });
cursor.y = orangeHeading(cursor.commands, margin, cursor.y, page.width - margin * 2, title, layout.headingSize);
cursor.y -= 6;
if (table.note) {
for (const line of wrap(table.note, layout.bodySize, page.width - margin * 2)) {
cursor.commands.push(textOp(margin, cursor.y, layout.bodySize, line, MUTED));
cursor.y -= layout.bodySize + 4;
}
cursor.y -= 8;
}
cursor.commands.push(textOp(margin, cursor.y, layout.bodySize, labels.column, INK));
cursor.y -= layout.bodySize + 6;
cursor.y = drawTable(cursor, columnRows(table, labels), margin, page.width - margin * 2, layout.bodySize, bottom, nextPage, ensure, [0.55, 1.6, 1.3, 0.9, 1.8]);
if (layout.includeIndexesAndForeignKeys && table.indexes.length > 0) {
cursor.y = quietLabel(cursor, labels.indexesHeading, margin, layout.bodySize, ensure);
cursor.y = drawTable(cursor, indexRows(table, labels), margin, page.width - margin * 2, layout.bodySize, bottom, nextPage, ensure, [1.8, 1.3, 0.7, 0.7, 0.9, 1.6]);
}
if (layout.includeIndexesAndForeignKeys && table.foreignKeys.length > 0) {
cursor.y = quietLabel(cursor, labels.foreignKeysHeading, margin, layout.bodySize, ensure);
cursor.y = drawTable(cursor, foreignKeyRows(table, labels), margin, page.width - margin * 2, layout.bodySize, bottom, nextPage, ensure, [1.7, 1.2, 1.1, 1.2, 1, 0.9, 1]);
}
cursor.y -= 16;
}
flush();
return { pages, placed };
}
function quietLabel(cursor: { commands: string[]; y: number }, title: string, x: number, size: number, ensure: (height: number) => void): number {
ensure(size + 28);
const y = cursor.y - 20;
cursor.commands.push(fill(x, y - 1, 18, 2, ORANGE));
cursor.commands.push(textOp(x + 24, y - size * 0.2, size, title, ORANGE));
return y - size - 8;
}
function kindLabel(table: DictionaryTable, labels: DataDictionaryLabels): string {
if (table.kind === "VIEW") return labels.kindView;
if (table.kind === "MATERIALIZED_VIEW") return labels.kindMaterializedView;
return labels.kindTable;
}
function columnRows(table: DictionaryTable, labels: DataDictionaryLabels): string[][] {
return [
["#", labels.column, labels.type, labels.nullable, labels.extra],
...table.columns.map((column, index) => [String(index + 1), column.name, typeLabel(column), column.is_nullable ? "" : CHECK, [column.extra, columnComment(table, column)].filter((part) => part && part.trim() !== "").join(" ")]),
];
}
function indexRows(table: DictionaryTable, labels: DataDictionaryLabels): string[][] {
return [
[labels.indexName, labels.indexColumns, labels.unique, labels.primaryKey, labels.indexType, labels.comment],
...table.indexes.map((index) => [index.name, index.columns.join(", "), yesNo(index.is_unique, labels), yesNo(index.is_primary, labels), index.index_type ?? "", index.comment ?? ""]),
];
}
function foreignKeyRows(table: DictionaryTable, labels: DataDictionaryLabels): string[][] {
return [[labels.constraintName, labels.column, labels.refSchema, labels.refTable, labels.refColumn, labels.onUpdate, labels.onDelete], ...table.foreignKeys.map((key) => [key.name, key.column, key.ref_schema ?? "", key.ref_table, key.ref_column, key.on_update ?? "", key.on_delete ?? ""])];
}
function yesNo(value: boolean, labels: DataDictionaryLabels): string {
return value ? labels.yes : labels.no;
}
function drawTable(cursor: { commands: string[]; y: number }, rows: string[][], x: number, width: number, size: number, bottom: number, nextPage: () => void, ensure: (height: number) => void, weights?: number[]): number {
if (rows.length === 0 || !rows[0]) return cursor.y;
const header = rows[0];
const used = weights && weights.length === header.length ? weights : header.map((_, index) => (index === header.length - 1 ? 2.2 : 1));
const weightSum = used.reduce((sum, weight) => sum + weight, 0);
const widths = used.map((weight) => (width * weight) / weightSum);
const rowHeight = (row: string[]) => {
const wrapped = row.map((cell, index) => wrap(cell, size, Math.max(8, widths[index]! - 8)));
return { wrapped, height: Math.max(1, ...wrapped.map((item) => item.length)) * (size + 3) + 8 };
};
const paint = (row: string[], headerRow: boolean, zebra: boolean) => {
const painted = rowHeight(row);
ensure(painted.height);
const top = cursor.y;
const boxY = top - painted.height;
if (headerRow) cursor.commands.push(fill(x, boxY, width, painted.height, PEACH));
else if (zebra) cursor.commands.push(fill(x, boxY, width, painted.height, "0.995 0.98 0.96"));
cursor.commands.push(`${GRID} RG 0.7 w ${num(x)} ${num(boxY)} ${num(width)} ${num(painted.height)} re S`);
let columnX = x;
const linePitch = size + 3;
const block = painted.wrapped.reduce((max, cell) => Math.max(max, cell.length), 1) * linePitch - 3;
const firstBaseline = boxY + (painted.height - block) / 2 + size * 0.78;
painted.wrapped.forEach((cell, index) => {
if (index > 0) cursor.commands.push(`${GRID} RG 0.5 w ${num(columnX)} ${num(boxY)} m ${num(columnX)} ${num(boxY + painted.height)} l S`);
if (cell.length === 1 && cell[0] === CHECK) {
const markX = columnX + widths[index]! / 2;
const markY = boxY + painted.height / 2;
cursor.commands.push(`${INK} RG 1.15 w ${num(markX - 3.4)} ${num(markY - 0.4)} m ${num(markX - 0.8)} ${num(markY - 3.2)} l ${num(markX + 4.2)} ${num(markY + 3.2)} l S`);
} else {
cell.forEach((line, lineIndex) => {
const lineWidth = textWidth(line, size);
const textX = columnX + Math.max(3, (widths[index]! - lineWidth) / 2);
cursor.commands.push(textOp(textX, firstBaseline - lineIndex * linePitch, size, line, INK));
});
}
columnX += widths[index]!;
});
cursor.y -= painted.height;
};
paint(header, true, false);
rows.slice(1).forEach((row, index) => {
if (cursor.y - rowHeight(row).height < bottom) {
nextPage();
paint(header, true, false);
}
paint(row, false, index % 2 === 1);
});
return cursor.y;
}
function contentBottom(margin: number): number {
return Math.max(margin, 36);
}
function stampChrome(pages: Page[], layout: DictionaryLayout, page: { width: number; height: number }, margin: number, frontCount: number): void {
pages.forEach((item, index) => {
if (index < frontCount) return;
if (layout.includeBreadcrumbs && layout.header) item.commands.unshift(textOp(margin, page.height - margin + 6, Math.max(8, layout.bodySize - 1), layout.header, MUTED));
const footerY = 22;
if (layout.includeLeftFooter && layout.leftFooter) item.commands.push(textOp(margin, footerY, layout.bodySize, layout.leftFooter, MUTED));
else if (layout.title) item.commands.push(textOp(margin, footerY, layout.bodySize, layout.title, MUTED));
if (layout.includePageNumber) {
const label = String(index - frontCount + 1);
item.commands.push(textOp(page.width - margin - textWidth(label, layout.bodySize), footerY, layout.bodySize, label, MUTED));
}
});
}
function textOp(x: number, y: number, size: number, text: string, color = INK): string {
const parts = ["BT"];
let cursor = x;
let run = "";
let runAscii = false;
const flush = () => {
if (!run) return;
if (runAscii) parts.push(`${color} rg /F2 ${num(size)} Tf 1 0 0 1 ${num(cursor - textWidth(run, size))} ${num(y)} Tm (${escapePdf(run)}) Tj`);
else parts.push(`${color} rg /F1 ${num(size)} Tf 1 0 0 1 ${num(cursor - textWidth(run, size))} ${num(y)} Tm <${pdfUtf16Hex(run)}> Tj`);
run = "";
};
for (const char of text) {
const ascii = (char.codePointAt(0) ?? 0) < 128;
if (run && ascii !== runAscii) flush();
runAscii = ascii;
run += char;
cursor += charWidth(char, size);
}
flush();
parts.push("ET");
return parts.join(" ");
}
function escapePdf(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
function num(value: number): string {
return Number(value.toFixed(2)).toString();
}
function encodePdf(pages: Page[], size: { width: number; height: number }, title: string): Uint8Array {
const objects: string[] = [];
const pageIds: number[] = [];
objects[1] = "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>";
objects[3] = "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UTF16-H /DescendantFonts [5 0 R] >>";
objects[5] = "<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 /FontDescriptor 6 0 R >>";
objects[6] = "<< /Type /FontDescriptor /FontName /STSong-Light /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 620 /StemV 80 >>";
objects[7] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
let nextId = 8;
for (const page of pages) {
const contentId = nextId++;
const pageId = nextId++;
pageIds.push(pageId);
const stream = page.commands.join("\n");
objects[contentId] = `<< /Length ${byteLength(stream)} >>\nstream\n${stream}\nendstream`;
objects[pageId] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${num(size.width)} ${num(size.height)}] /Resources << /Font << /F1 3 0 R /F2 7 0 R >> >> /Contents ${contentId} 0 R >>`;
}
objects[2] = `<< /Type /Pages /Count ${pageIds.length} /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] >>`;
const bookmarks = pages.flatMap((page, index) => (page.bookmark ? [{ pageId: pageIds[index]!, title: page.bookmark }] : []));
if (bookmarks.length === 0) {
objects[4] = "<< /Type /Outlines /Count 0 >>";
} else {
const first = nextId;
bookmarks.forEach((item, index) => {
const id = nextId++;
const prev = index === 0 ? "" : ` /Prev ${id - 1} 0 R`;
const next = index === bookmarks.length - 1 ? "" : ` /Next ${id + 1} 0 R`;
objects[id] = `<< /Title <FEFF${pdfUtf16Hex(item.title)}> /Parent 4 0 R${prev}${next} /Dest [${item.pageId} 0 R /Fit] >>`;
});
objects[4] = `<< /Type /Outlines /First ${first} 0 R /Last ${nextId - 1} 0 R /Count ${bookmarks.length} >>`;
}
const infoId = nextId;
objects[infoId] = `<< /Title <FEFF${pdfUtf16Hex(title)}> >>`;
objects[1] = `<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /Info ${infoId} 0 R >>`;
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (let id = 1; id <= infoId; id++) {
offsets[id] = pdf.length;
pdf += `${id} 0 obj\n${objects[id] ?? "<<>>"}\nendobj\n`;
}
const xref = pdf.length;
pdf += `xref\n0 ${infoId + 1}\n0000000000 65535 f \n`;
for (let id = 1; id <= infoId; id++) pdf += `${String(offsets[id]).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${infoId + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
return new TextEncoder().encode(pdf);
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
@@ -0,0 +1,37 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export class DictionaryFileExistsError extends Error {
constructor() {
super("exists");
this.name = "DictionaryFileExistsError";
}
}
/**
* Suggested name is only the save dialog's defaultPath. The write uses the path
* `save()` just returned. Tauri's dialog grant covers that exact path, not a
* typed path or a timestamp appended after the dialog closes.
*/
export async function saveDataDictionaryFile(suggestedName: string, content: Uint8Array, options: { overwrite: boolean }): Promise<string | null> {
if (isTauriRuntime()) {
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const granted = await save({
defaultPath: suggestedName,
filters: [{ name: "PDF", extensions: ["pdf"] }],
});
if (!granted) return null;
if (!options.overwrite && (await fs.exists(granted))) throw new DictionaryFileExistsError();
await fs.writeFile(granted, content);
return granted;
}
const downloadName = suggestedName.split(/[/\\]/).pop() || suggestedName;
const blob = new Blob([content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength) as ArrayBuffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = downloadName;
anchor.click();
URL.revokeObjectURL(url);
return downloadName;
}
@@ -601,6 +601,12 @@ export const useConnectionStore = defineStore("connection", () => {
schema?: string;
tableName?: string;
} | null>(null);
const dataDictionarySource = ref<{
connectionId: string;
database: string;
schema?: string;
tableNames?: string[];
} | null>(null);
const tableImportSource = ref<{
connectionId: string;
database: string;
@@ -10080,6 +10086,7 @@ export const useConnectionStore = defineStore("connection", () => {
sqlFileSource,
diagramSource,
docsSource,
dataDictionarySource,
tableImportSource,
mongoDatabaseDumpSource,
mongoImportSource,