feat(data): support connection URLs and XLSX export
This commit is contained in:
parent
bd58ab6312
commit
a878ec203a
|
|
@ -21,6 +21,7 @@
|
|||
"dialog:allow-save",
|
||||
"dialog:allow-open",
|
||||
"fs:default",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-text-file",
|
||||
"process:default",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import { useToast } from "@/composables/useToast";
|
|||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, List, Search } from "lucide-vue-next";
|
||||
import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connectionUrl";
|
||||
import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, Link2, List, Search } from "lucide-vue-next";
|
||||
|
||||
type DbOption = { value: string; label: string };
|
||||
type DbCategory = { key: string; title: string; options: DbOption[] };
|
||||
|
|
@ -72,6 +73,7 @@ const form = ref(defaultForm());
|
|||
const selectedType = ref("mysql");
|
||||
const customDriverName = ref("");
|
||||
const mongoUseUrl = ref(false);
|
||||
const connectionUrlInput = ref("");
|
||||
const dialogStep = ref<DialogStep>("select");
|
||||
const dbPickerView = ref<DbPickerView>("icon");
|
||||
const dbSearchQuery = ref("");
|
||||
|
|
@ -445,6 +447,23 @@ function resetTestState() {
|
|||
testResult.value = null;
|
||||
}
|
||||
|
||||
function applyConnectionUrl() {
|
||||
try {
|
||||
const parsed = parseConnectionUrl(connectionUrlInput.value, selectedType.value);
|
||||
form.value = applyParsedConnectionUrl(form.value, parsed);
|
||||
selectedType.value = parsed.driverProfile;
|
||||
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
|
||||
mongoUseUrl.value = !!parsed.useMongoUrl;
|
||||
if (!form.value.name.trim()) {
|
||||
form.value.name = parsed.database || parsed.host || parsed.driverLabel;
|
||||
}
|
||||
resetTestState();
|
||||
toast(t("connection.parseConnectionUrlApplied"), 2000);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.parseConnectionUrlFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTestResult() {
|
||||
if (!testResultMessage.value) return;
|
||||
await navigator.clipboard.writeText(testResultMessage.value);
|
||||
|
|
@ -457,6 +476,7 @@ function resetForm() {
|
|||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
mongoUseUrl.value = false;
|
||||
connectionUrlInput.value = "";
|
||||
dialogStep.value = "select";
|
||||
dbPickerView.value = "icon";
|
||||
dbSearchQuery.value = "";
|
||||
|
|
@ -677,6 +697,33 @@ async function browseDbFilePath() {
|
|||
|
||||
<TabsContent value="connection" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">URL</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input
|
||||
v-model="connectionUrlInput"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.connectionUrlPlaceholder')"
|
||||
@keydown.enter.prevent="applyConnectionUrl"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="!connectionUrlInput.trim()"
|
||||
:aria-label="t('connection.parseConnectionUrl')"
|
||||
@click="applyConnectionUrl"
|
||||
>
|
||||
<Link2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.parseConnectionUrl") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.name") }}</Label>
|
||||
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import {
|
|||
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { formatMarkdownTable } from "@/lib/markdownTable";
|
||||
import { buildXlsxWorkbook } from "@/lib/xlsxExport";
|
||||
import {
|
||||
matchesRowStatusFilter,
|
||||
rowStatusFilterAfterAddingRow,
|
||||
|
|
@ -1592,7 +1593,12 @@ function copyAll() {
|
|||
copyText(`${header}\n${body}`);
|
||||
}
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
async function saveFileContent(
|
||||
content: string,
|
||||
defaultFileName: string,
|
||||
filterName: string,
|
||||
filterExt: string,
|
||||
): Promise<boolean> {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
|
|
@ -1600,7 +1606,9 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
|||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, "" + content);
|
||||
if (!path) return false;
|
||||
await writeTextFile(path, "" + content);
|
||||
return true;
|
||||
} else {
|
||||
const blob = new Blob(["", content], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -1609,32 +1617,96 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
|||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBinaryFileContent(
|
||||
content: Uint8Array,
|
||||
defaultFileName: string,
|
||||
filterName: string,
|
||||
filterExt: string,
|
||||
): Promise<boolean> {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (!path) return false;
|
||||
await writeFile(path, content);
|
||||
return true;
|
||||
} else {
|
||||
const blob = new Blob([content], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = props.result.columns.map(escape).join(",");
|
||||
const body = displayItems.value.map((item) => item.data.map((c) => escape(formatCell(c))).join(",")).join("\n");
|
||||
await saveFileContent(`${header}\n${body}`, "export.csv", "CSV", "csv");
|
||||
try {
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = props.result.columns.map(escape).join(",");
|
||||
const body = displayItems.value.map((item) => item.data.map((c) => escape(formatCell(c))).join(",")).join("\n");
|
||||
if (await saveFileContent(`${header}\n${body}`, "export.csv", "CSV", "csv")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJson() {
|
||||
const data = displayItems.value.map((item) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
try {
|
||||
const data = displayItems.value.map((item) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
await saveFileContent(JSON.stringify(data, null, 2), "export.json", "JSON", "json");
|
||||
if (await saveFileContent(JSON.stringify(data, null, 2), "export.json", "JSON", "json")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMarkdown() {
|
||||
const cols = props.result.columns;
|
||||
const visibleRows = displayItems.value.map((item) => item.data);
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
await saveFileContent(md, "export.md", "Markdown", "md");
|
||||
try {
|
||||
const cols = props.result.columns;
|
||||
const visibleRows = displayItems.value.map((item) => item.data);
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
if (await saveFileContent(md, "export.md", "Markdown", "md")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportXlsx() {
|
||||
try {
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: props.tableMeta?.tableName || "Export",
|
||||
columns: props.result.columns,
|
||||
rows: displayItems.value.map((item) => item.data),
|
||||
});
|
||||
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "");
|
||||
|
|
@ -2427,6 +2499,7 @@ defineExpose({
|
|||
<ContextMenuSubTrigger> <FileDown class="w-3.5 h-3.5 mr-2" /> {{ t("grid.export") }} </ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent class="w-max max-w-[min(80vw,16rem)]">
|
||||
<ContextMenuItem @click="exportCsv">{{ t("grid.exportCsv") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportXlsx">{{ t("grid.exportXlsx") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportJson">{{ t("grid.exportJson") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportMarkdown">{{ t("grid.exportMarkdown") }}</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
|
|
@ -2497,6 +2570,7 @@ defineExpose({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="exportCsv">{{ t("grid.exportCsv") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportXlsx">{{ t("grid.exportXlsx") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportJson">{{ t("grid.exportJson") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportMarkdown">{{ t("grid.exportMarkdown") }}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export default {
|
|||
driverName: "Driver Name",
|
||||
driverNamePlaceholder: "Vendor or environment name",
|
||||
urlParams: "URL Params",
|
||||
connectionUrlPlaceholder: "postgresql://user:pass@host:5432/db?sslmode=require",
|
||||
parseConnectionUrl: "Parse connection URL",
|
||||
parseConnectionUrlApplied: "Connection URL applied",
|
||||
parseConnectionUrlFailed: "Failed to parse connection URL: {message}",
|
||||
mode: "Connection Mode",
|
||||
modeForm: "Form",
|
||||
searchDatabasePlaceholder: "Search database types",
|
||||
|
|
@ -187,8 +191,11 @@ export default {
|
|||
clearSelection: "Clear Selection",
|
||||
export: "Export",
|
||||
exportCsv: "Export CSV",
|
||||
exportXlsx: "Export XLSX",
|
||||
exportJson: "Export JSON",
|
||||
exportMarkdown: "Export Markdown",
|
||||
exported: "Exported",
|
||||
exportFailed: "Export failed: {message}",
|
||||
filter: "Filter",
|
||||
filterByValue: "Filter by This Value",
|
||||
filterExcludeValue: "Exclude This Value",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ export default {
|
|||
driverName: "驱动名称",
|
||||
driverNamePlaceholder: "厂商或环境名称",
|
||||
urlParams: "URL 参数",
|
||||
connectionUrlPlaceholder: "postgresql://user:pass@host:5432/db?sslmode=require",
|
||||
parseConnectionUrl: "解析连接 URL",
|
||||
parseConnectionUrlApplied: "已应用连接 URL",
|
||||
parseConnectionUrlFailed: "解析连接 URL 失败:{message}",
|
||||
mode: "连接方式",
|
||||
modeForm: "表单",
|
||||
searchDatabasePlaceholder: "搜索数据库类型",
|
||||
|
|
@ -186,8 +190,11 @@ export default {
|
|||
clearSelection: "清除选区",
|
||||
export: "导出",
|
||||
exportCsv: "导出 CSV",
|
||||
exportXlsx: "导出 XLSX",
|
||||
exportJson: "导出 JSON",
|
||||
exportMarkdown: "导出 Markdown",
|
||||
exported: "导出完成",
|
||||
exportFailed: "导出失败:{message}",
|
||||
filter: "筛选",
|
||||
filterByValue: "筛选此值",
|
||||
filterExcludeValue: "排除此值",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
|
||||
export interface ParsedConnectionUrl {
|
||||
dbType: DatabaseType;
|
||||
driverProfile: string;
|
||||
driverLabel: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database?: string;
|
||||
urlParams: string;
|
||||
ssl: boolean;
|
||||
connectionString?: string;
|
||||
useMongoUrl?: boolean;
|
||||
}
|
||||
|
||||
type ConnectionProfile = {
|
||||
type: DatabaseType;
|
||||
profile: string;
|
||||
label: string;
|
||||
defaultPort: number;
|
||||
};
|
||||
|
||||
const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
|
||||
mysql: { type: "mysql", profile: "mysql", label: "MySQL", defaultPort: 3306 },
|
||||
mariadb: { type: "mysql", profile: "mariadb", label: "MariaDB", defaultPort: 3306 },
|
||||
postgres: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 },
|
||||
postgresql: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 },
|
||||
redshift: { type: "redshift", profile: "redshift", label: "Redshift", defaultPort: 5439 },
|
||||
redis: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 },
|
||||
rediss: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 },
|
||||
mongodb: { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
|
||||
"mongodb+srv": { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
|
||||
clickhouse: { type: "clickhouse", profile: "clickhouse", label: "ClickHouse", defaultPort: 8123 },
|
||||
sqlserver: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
|
||||
mssql: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
|
||||
oracle: { type: "oracle", profile: "oracle", label: "Oracle", defaultPort: 1521 },
|
||||
elasticsearch: { type: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", defaultPort: 9200 },
|
||||
dm: { type: "dameng", profile: "dm", label: "DM (Dameng)", defaultPort: 5236 },
|
||||
dameng: { type: "dameng", profile: "dm", label: "DM (Dameng)", defaultPort: 5236 },
|
||||
gaussdb: { type: "gaussdb", profile: "gaussdb", label: "GaussDB", defaultPort: 5432 },
|
||||
opengauss: { type: "gaussdb", profile: "opengauss", label: "openGauss", defaultPort: 5432 },
|
||||
};
|
||||
|
||||
const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
|
||||
clickhouse: SCHEME_PROFILES.clickhouse,
|
||||
elasticsearch: SCHEME_PROFILES.elasticsearch,
|
||||
};
|
||||
|
||||
function decodeUrlPart(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function databaseFromPath(pathname: string): string | undefined {
|
||||
const value = pathname.replace(/^\/+/, "");
|
||||
if (!value) return undefined;
|
||||
return decodeUrlPart(value.split("/")[0]);
|
||||
}
|
||||
|
||||
function profileForScheme(scheme: string, preferredProfile?: string): ConnectionProfile | undefined {
|
||||
if ((scheme === "http" || scheme === "https") && preferredProfile) {
|
||||
return HTTP_SELECTED_PROFILES[preferredProfile];
|
||||
}
|
||||
return SCHEME_PROFILES[scheme];
|
||||
}
|
||||
|
||||
export function parseConnectionUrl(value: string, preferredProfile?: string): ParsedConnectionUrl {
|
||||
const source = value.trim();
|
||||
if (!source) {
|
||||
throw new Error("Connection URL is empty");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(source);
|
||||
} catch {
|
||||
throw new Error("Invalid connection URL");
|
||||
}
|
||||
|
||||
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
||||
const profile = profileForScheme(scheme, preferredProfile);
|
||||
if (!profile) {
|
||||
throw new Error(`Unsupported connection URL scheme: ${scheme}`);
|
||||
}
|
||||
|
||||
const urlParams = parsed.search.replace(/^\?/, "");
|
||||
if (profile.type === "mongodb") {
|
||||
return {
|
||||
dbType: profile.type,
|
||||
driverProfile: profile.profile,
|
||||
driverLabel: profile.label,
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : profile.defaultPort,
|
||||
username: decodeUrlPart(parsed.username),
|
||||
password: decodeUrlPart(parsed.password),
|
||||
database: databaseFromPath(parsed.pathname),
|
||||
urlParams,
|
||||
ssl: scheme === "mongodb+srv",
|
||||
connectionString: source,
|
||||
useMongoUrl: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dbType: profile.type,
|
||||
driverProfile: profile.profile,
|
||||
driverLabel: profile.label,
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : profile.defaultPort,
|
||||
username: decodeUrlPart(parsed.username),
|
||||
password: decodeUrlPart(parsed.password),
|
||||
database: databaseFromPath(parsed.pathname),
|
||||
urlParams,
|
||||
ssl: scheme === "rediss" || scheme === "https",
|
||||
};
|
||||
}
|
||||
|
||||
export function applyParsedConnectionUrl(
|
||||
config: Omit<ConnectionConfig, "id">,
|
||||
parsed: ParsedConnectionUrl,
|
||||
): Omit<ConnectionConfig, "id"> {
|
||||
return {
|
||||
...config,
|
||||
db_type: parsed.dbType,
|
||||
driver_profile: parsed.driverProfile,
|
||||
driver_label: parsed.driverLabel,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
url_params: parsed.urlParams,
|
||||
ssl: parsed.ssl,
|
||||
connection_string: parsed.connectionString,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
export type XlsxCellValue = string | number | boolean | null | undefined;
|
||||
|
||||
export interface XlsxWorksheetData {
|
||||
sheetName?: string;
|
||||
columns: readonly string[];
|
||||
rows: readonly (readonly XlsxCellValue[])[];
|
||||
}
|
||||
|
||||
type ZipEntry = {
|
||||
path: string;
|
||||
data: Uint8Array;
|
||||
crc: number;
|
||||
localHeaderOffset: number;
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const CRC_TABLE = buildCrcTable();
|
||||
|
||||
function buildCrcTable(): number[] {
|
||||
const table: number[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table.push(c >>> 0);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function columnName(index: number): string {
|
||||
let value = "";
|
||||
let n = index + 1;
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
value = String.fromCharCode(65 + rem) + value;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cellRef(rowIndex: number, colIndex: number): string {
|
||||
return `${columnName(colIndex)}${rowIndex + 1}`;
|
||||
}
|
||||
|
||||
function sheetRange(columnCount: number, rowCount: number): string {
|
||||
if (columnCount === 0 || rowCount === 0) return "A1";
|
||||
return `A1:${columnName(columnCount - 1)}${rowCount}`;
|
||||
}
|
||||
|
||||
function normalizeSheetName(value?: string): string {
|
||||
const name = (value || "Sheet1").replace(/[\[\]:*?/\\]/g, " ").trim() || "Sheet1";
|
||||
return name.slice(0, 31);
|
||||
}
|
||||
|
||||
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[]): number[] {
|
||||
return columns.map((column, colIndex) => {
|
||||
const values = rows.slice(0, 100).map((row) => row[colIndex]);
|
||||
const maxLen = [column, ...values.map((value) => (value == null ? "" : String(value)))]
|
||||
.map((value) => Math.min(value.length, 60))
|
||||
.reduce((max, length) => Math.max(max, length), 8);
|
||||
return Math.max(10, Math.min(60, maxLen + 2));
|
||||
});
|
||||
}
|
||||
|
||||
function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style?: number): string {
|
||||
const ref = cellRef(rowIndex, colIndex);
|
||||
const styleAttr = style == null ? "" : ` s="${style}"`;
|
||||
if (value == null) return `<c r="${ref}"${styleAttr}/>`;
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return `<c r="${ref}"${styleAttr}><v>${value}</v></c>`;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return `<c r="${ref}" t="b"${styleAttr}><v>${value ? 1 : 0}</v></c>`;
|
||||
}
|
||||
return `<c r="${ref}" t="inlineStr"${styleAttr}><is><t>${escapeXml(String(value))}</t></is></c>`;
|
||||
}
|
||||
|
||||
function worksheetXml(data: XlsxWorksheetData): string {
|
||||
const columns = data.columns;
|
||||
const rows = data.rows;
|
||||
const totalRows = rows.length + 1;
|
||||
const range = sheetRange(columns.length, totalRows);
|
||||
const widths = estimateColumnWidths(columns, rows);
|
||||
const colsXml = widths
|
||||
.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`)
|
||||
.join("");
|
||||
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(column, 0, index, 1)).join("")}</row>`;
|
||||
const bodyXml = rows
|
||||
.map((row, rowIndex) => {
|
||||
const excelRowIndex = rowIndex + 2;
|
||||
const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex)).join("");
|
||||
return `<row r="${excelRowIndex}">${cells}</row>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<dimension ref="${range}"/>
|
||||
<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>
|
||||
<sheetFormatPr defaultRowHeight="15"/>
|
||||
<cols>${colsXml}</cols>
|
||||
<sheetData>${headerXml}${bodyXml}</sheetData>
|
||||
<autoFilter ref="${range}"/>
|
||||
</worksheet>`;
|
||||
}
|
||||
|
||||
function contentTypesXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>`;
|
||||
}
|
||||
|
||||
function rootRelsXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`;
|
||||
}
|
||||
|
||||
function workbookXml(sheetName: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="${escapeXml(sheetName)}" sheetId="1" r:id="rId1"/></sheets>
|
||||
</workbook>`;
|
||||
}
|
||||
|
||||
function workbookRelsXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`;
|
||||
}
|
||||
|
||||
function stylesXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
|
||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||
</styleSheet>`;
|
||||
}
|
||||
|
||||
function uint16(value: number): Uint8Array {
|
||||
const bytes = new Uint8Array(2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint16(0, value, true);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function uint32(value: number): Uint8Array {
|
||||
const bytes = new Uint8Array(4);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, value >>> 0, true);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createZip(files: Array<{ path: string; content: string }>): Uint8Array {
|
||||
const entries: ZipEntry[] = [];
|
||||
const localParts: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const path = encoder.encode(file.path);
|
||||
const data = encoder.encode(file.content);
|
||||
const crc = crc32(data);
|
||||
const localHeader = concatBytes([
|
||||
uint32(0x04034b50),
|
||||
uint16(20),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint32(crc),
|
||||
uint32(data.length),
|
||||
uint32(data.length),
|
||||
uint16(path.length),
|
||||
uint16(0),
|
||||
path,
|
||||
]);
|
||||
entries.push({ path: file.path, data, crc, localHeaderOffset: offset });
|
||||
localParts.push(localHeader, data);
|
||||
offset += localHeader.length + data.length;
|
||||
}
|
||||
|
||||
const centralParts: Uint8Array[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = encoder.encode(entry.path);
|
||||
centralParts.push(
|
||||
concatBytes([
|
||||
uint32(0x02014b50),
|
||||
uint16(20),
|
||||
uint16(20),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint32(entry.crc),
|
||||
uint32(entry.data.length),
|
||||
uint32(entry.data.length),
|
||||
uint16(path.length),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint32(0),
|
||||
uint32(entry.localHeaderOffset),
|
||||
path,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const central = concatBytes(centralParts);
|
||||
const end = concatBytes([
|
||||
uint32(0x06054b50),
|
||||
uint16(0),
|
||||
uint16(0),
|
||||
uint16(entries.length),
|
||||
uint16(entries.length),
|
||||
uint32(central.length),
|
||||
uint32(offset),
|
||||
uint16(0),
|
||||
]);
|
||||
|
||||
return concatBytes([...localParts, central, end]);
|
||||
}
|
||||
|
||||
export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array {
|
||||
const sheetName = normalizeSheetName(data.sheetName);
|
||||
return createZip([
|
||||
{ path: "[Content_Types].xml", content: contentTypesXml() },
|
||||
{ path: "_rels/.rels", content: rootRelsXml() },
|
||||
{ path: "xl/workbook.xml", content: workbookXml(sheetName) },
|
||||
{ path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml() },
|
||||
{ path: "xl/styles.xml", content: stylesXml() },
|
||||
{ path: "xl/worksheets/sheet1.xml", content: worksheetXml(data) },
|
||||
]);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { parseConnectionUrl } from "../src/lib/connectionUrl.ts";
|
||||
|
||||
test("parses postgres connection URLs", () => {
|
||||
assert.deepEqual(parseConnectionUrl("postgresql://alice:secret@db.example.com:5433/app?sslmode=require"), {
|
||||
dbType: "postgres",
|
||||
driverProfile: "postgres",
|
||||
driverLabel: "PostgreSQL",
|
||||
host: "db.example.com",
|
||||
port: 5433,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
database: "app",
|
||||
urlParams: "sslmode=require",
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses mysql URLs with encoded credentials", () => {
|
||||
const parsed = parseConnectionUrl("mysql://root:p%40ss@127.0.0.1/shop?charset=utf8mb4");
|
||||
|
||||
assert.equal(parsed.dbType, "mysql");
|
||||
assert.equal(parsed.driverProfile, "mysql");
|
||||
assert.equal(parsed.host, "127.0.0.1");
|
||||
assert.equal(parsed.port, 3306);
|
||||
assert.equal(parsed.username, "root");
|
||||
assert.equal(parsed.password, "p@ss");
|
||||
assert.equal(parsed.database, "shop");
|
||||
assert.equal(parsed.urlParams, "charset=utf8mb4");
|
||||
});
|
||||
|
||||
test("keeps MongoDB URLs as connection strings", () => {
|
||||
const source = "mongodb+srv://reader:secret@cluster.example.com/app?retryWrites=true";
|
||||
const parsed = parseConnectionUrl(source);
|
||||
|
||||
assert.equal(parsed.dbType, "mongodb");
|
||||
assert.equal(parsed.driverProfile, "mongodb");
|
||||
assert.equal(parsed.host, "cluster.example.com");
|
||||
assert.equal(parsed.port, 27017);
|
||||
assert.equal(parsed.database, "app");
|
||||
assert.equal(parsed.connectionString, source);
|
||||
assert.equal(parsed.useMongoUrl, true);
|
||||
assert.equal(parsed.ssl, true);
|
||||
});
|
||||
|
||||
test("uses selected HTTP-compatible profile for HTTP URLs", () => {
|
||||
const parsed = parseConnectionUrl("https://search.example.com:9243", "elasticsearch");
|
||||
|
||||
assert.equal(parsed.dbType, "elasticsearch");
|
||||
assert.equal(parsed.driverProfile, "elasticsearch");
|
||||
assert.equal(parsed.host, "search.example.com");
|
||||
assert.equal(parsed.port, 9243);
|
||||
assert.equal(parsed.ssl, true);
|
||||
});
|
||||
|
||||
test("rejects unsupported URL schemes", () => {
|
||||
assert.throws(() => parseConnectionUrl("ftp://example.com"), /Unsupported connection URL scheme/);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildXlsxWorkbook } from "../src/lib/xlsxExport.ts";
|
||||
|
||||
test("builds an xlsx workbook zip with worksheet data", () => {
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: "Users",
|
||||
columns: ["id", "name", "active"],
|
||||
rows: [
|
||||
[1, "Ada & Bob", true],
|
||||
[2, null, false],
|
||||
],
|
||||
});
|
||||
const text = new TextDecoder().decode(workbook);
|
||||
|
||||
assert.equal(workbook[0], 0x50);
|
||||
assert.equal(workbook[1], 0x4b);
|
||||
assert.match(text, /\[Content_Types\]\.xml/);
|
||||
assert.match(text, /xl\/worksheets\/sheet1\.xml/);
|
||||
assert.match(text, /name="Users"/);
|
||||
assert.match(text, /<c r="A2"><v>1<\/v><\/c>/);
|
||||
assert.match(text, /Ada & Bob/);
|
||||
assert.match(text, /<c r="C2" t="b"><v>1<\/v><\/c>/);
|
||||
});
|
||||
|
||||
test("sanitizes invalid sheet names", () => {
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: "bad/name:with*chars?and-a-very-long-tail",
|
||||
columns: ["value"],
|
||||
rows: [["ok"]],
|
||||
});
|
||||
const text = new TextDecoder().decode(workbook);
|
||||
|
||||
assert.match(text, /name="bad name with chars and-a-very-"/);
|
||||
});
|
||||
Loading…
Reference in New Issue