forked from baron/baron-sso
265 lines
6.6 KiB
TypeScript
265 lines
6.6 KiB
TypeScript
import type { TenantSummary } from "../../../lib/adminApi";
|
|
|
|
export type TenantCSVRow = {
|
|
rowNumber: number;
|
|
tenantId: string;
|
|
name: string;
|
|
type: string;
|
|
parentTenantId: string;
|
|
slug: string;
|
|
memo: string;
|
|
emailDomain: string;
|
|
};
|
|
|
|
export type TenantImportCandidate = {
|
|
tenantId: string;
|
|
name: string;
|
|
slug: string;
|
|
score: number;
|
|
reason: "exact_name" | "exact_slug" | "similar_name";
|
|
};
|
|
|
|
export type TenantImportPreviewRow = {
|
|
row: TenantCSVRow;
|
|
candidates: TenantImportCandidate[];
|
|
defaultTenantId: string;
|
|
};
|
|
|
|
const importHeaders = [
|
|
"tenant_id",
|
|
"name",
|
|
"type",
|
|
"parent_tenant_id",
|
|
"slug",
|
|
"memo",
|
|
"email_domain",
|
|
];
|
|
|
|
const headerAliases: Record<string, keyof TenantCSVRow> = {
|
|
id: "tenantId",
|
|
tenantid: "tenantId",
|
|
tenant_id: "tenantId",
|
|
name: "name",
|
|
type: "type",
|
|
parentid: "parentTenantId",
|
|
parent_id: "parentTenantId",
|
|
parenttenantid: "parentTenantId",
|
|
parent_tenant_id: "parentTenantId",
|
|
slug: "slug",
|
|
memo: "memo",
|
|
description: "memo",
|
|
"email-domain": "emailDomain",
|
|
emaildomain: "emailDomain",
|
|
email_domain: "emailDomain",
|
|
domain: "emailDomain",
|
|
domains: "emailDomain",
|
|
};
|
|
|
|
export function parseTenantCSV(text: string): TenantCSVRow[] {
|
|
const records = parseCSV(text.replace(/^\uFEFF/, ""));
|
|
if (records.length === 0) return [];
|
|
|
|
const header = new Map<keyof TenantCSVRow, number>();
|
|
records[0].forEach((column, index) => {
|
|
const normalized = normalizeHeader(column);
|
|
const key = headerAliases[normalized];
|
|
if (key) header.set(key, index);
|
|
});
|
|
|
|
return records.slice(1).flatMap((record, index) => {
|
|
if (record.every((value) => value.trim() === "")) return [];
|
|
const value = (key: keyof TenantCSVRow) => {
|
|
const columnIndex = header.get(key);
|
|
if (columnIndex === undefined) return "";
|
|
return (record[columnIndex] ?? "").trim();
|
|
};
|
|
|
|
return {
|
|
rowNumber: index + 2,
|
|
tenantId: value("tenantId"),
|
|
name: value("name"),
|
|
type: value("type"),
|
|
parentTenantId: value("parentTenantId"),
|
|
slug: value("slug"),
|
|
memo: value("memo"),
|
|
emailDomain: value("emailDomain"),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function buildTenantImportPreview(
|
|
rows: TenantCSVRow[],
|
|
tenants: TenantSummary[],
|
|
): TenantImportPreviewRow[] {
|
|
return rows
|
|
.map((row) => {
|
|
const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants);
|
|
return {
|
|
row,
|
|
candidates,
|
|
defaultTenantId:
|
|
candidates[0] && candidates[0].score >= 0.95
|
|
? candidates[0].tenantId
|
|
: "",
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
const aScore = a.candidates[0]?.score ?? 0;
|
|
const bScore = b.candidates[0]?.score ?? 0;
|
|
if (bScore !== aScore) return bScore - aScore;
|
|
return a.row.rowNumber - b.row.rowNumber;
|
|
});
|
|
}
|
|
|
|
export function serializeTenantImportCSV(
|
|
previewRows: TenantImportPreviewRow[],
|
|
selectedTenantIds: Record<number, string>,
|
|
) {
|
|
const lines = [importHeaders];
|
|
for (const preview of [...previewRows].sort(
|
|
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
|
)) {
|
|
const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? "";
|
|
lines.push([
|
|
preview.row.tenantId || selectedTenantId,
|
|
preview.row.name,
|
|
preview.row.type,
|
|
preview.row.parentTenantId,
|
|
preview.row.slug,
|
|
preview.row.memo,
|
|
preview.row.emailDomain,
|
|
]);
|
|
}
|
|
return lines.map(formatCSVRecord).join("\n") + "\n";
|
|
}
|
|
|
|
function findTenantCandidates(
|
|
row: TenantCSVRow,
|
|
tenants: TenantSummary[],
|
|
): TenantImportCandidate[] {
|
|
return tenants
|
|
.map((tenant) => {
|
|
const nameScore = similarity(row.name, tenant.name);
|
|
const slugScore =
|
|
normalizeToken(row.slug) &&
|
|
normalizeToken(row.slug) === normalizeToken(tenant.slug)
|
|
? 0.98
|
|
: 0;
|
|
const exactName =
|
|
normalizeToken(row.name) === normalizeToken(tenant.name);
|
|
const score = exactName ? 1 : Math.max(slugScore, nameScore);
|
|
const reason: TenantImportCandidate["reason"] = exactName
|
|
? "exact_name"
|
|
: slugScore >= 0.98
|
|
? "exact_slug"
|
|
: "similar_name";
|
|
return {
|
|
tenantId: tenant.id,
|
|
name: tenant.name,
|
|
slug: tenant.slug,
|
|
score,
|
|
reason,
|
|
};
|
|
})
|
|
.filter((candidate) => candidate.score >= 0.45)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 3);
|
|
}
|
|
|
|
function parseCSV(text: string): string[][] {
|
|
const rows: string[][] = [];
|
|
let current = "";
|
|
let row: string[] = [];
|
|
let quoted = false;
|
|
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
const next = text[i + 1];
|
|
|
|
if (char === '"' && quoted && next === '"') {
|
|
current += '"';
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
quoted = !quoted;
|
|
continue;
|
|
}
|
|
if (char === "," && !quoted) {
|
|
row.push(current);
|
|
current = "";
|
|
continue;
|
|
}
|
|
if ((char === "\n" || char === "\r") && !quoted) {
|
|
if (char === "\r" && next === "\n") i += 1;
|
|
row.push(current);
|
|
rows.push(row);
|
|
row = [];
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
|
|
if (current !== "" || row.length > 0) {
|
|
row.push(current);
|
|
rows.push(row);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function formatCSVRecord(record: string[]) {
|
|
return record
|
|
.map((value) => {
|
|
if (!/[",\r\n]/.test(value)) return value;
|
|
return `"${value.replaceAll('"', '""')}"`;
|
|
})
|
|
.join(",");
|
|
}
|
|
|
|
function normalizeHeader(value: string) {
|
|
return value.trim().toLowerCase().replaceAll(" ", "_");
|
|
}
|
|
|
|
function normalizeToken(value: string) {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_-]+/g, "")
|
|
.replace(/[^\p{L}\p{N}]/gu, "");
|
|
}
|
|
|
|
function similarity(left: string, right: string) {
|
|
const a = normalizeToken(left);
|
|
const b = normalizeToken(right);
|
|
if (!a || !b) return 0;
|
|
if (a === b) return 1;
|
|
if (a.includes(b) || b.includes(a)) {
|
|
return Math.min(a.length, b.length) / Math.max(a.length, b.length);
|
|
}
|
|
|
|
const distance = levenshtein(a, b);
|
|
return 1 - distance / Math.max(a.length, b.length);
|
|
}
|
|
|
|
function levenshtein(left: string, right: string) {
|
|
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
|
|
const current = Array.from({ length: right.length + 1 }, () => 0);
|
|
|
|
for (let i = 1; i <= left.length; i += 1) {
|
|
current[0] = i;
|
|
for (let j = 1; j <= right.length; j += 1) {
|
|
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
current[j] = Math.min(
|
|
current[j - 1] + 1,
|
|
previous[j] + 1,
|
|
previous[j - 1] + cost,
|
|
);
|
|
}
|
|
previous.splice(0, previous.length, ...current);
|
|
}
|
|
|
|
return previous[right.length];
|
|
}
|