feat(docs): export data dictionary as PDF
This commit is contained in:
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 });
|
||||
|
||||
+48
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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: "更新ボタンの左側(フローティング)",
|
||||
|
||||
@@ -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: "업데이트 버튼 왼쪽 (플로팅)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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: "Преобразуйте текущее значение для просмотра или копирования. Исходное значение и черновик редактора не изменяются.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "更新按钮左侧(浮动)",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user