Files
pinkudex/app/actions/actressImport.ts
T
2026-05-26 22:46:00 +02:00

163 lines
6.0 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
export interface ImportPreviewLine {
raw: string;
name: string;
altNames: string | null;
categories: string[];
status: "new" | "exists" | "blank" | "error";
reason?: string;
}
export interface ImportResult {
lines: ImportPreviewLine[];
added: number;
skipped: number;
errors: number;
newCategories: string[];
}
function parseLines(text: string): Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> {
const out: Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> = [];
for (const raw of text.split(/\r?\n/)) {
const trimmed = raw.trim();
if (!trimmed) {
out.push({ raw, name: "", altNames: null, categories: [] });
continue;
}
const parts = trimmed.split("|").map((s) => s.trim());
const [name, alt, cats] = [parts[0] ?? "", parts[1] ?? "", parts[2] ?? ""];
const categories = cats ? cats.split(/[,、,]/).map((s) => s.trim()).filter(Boolean) : [];
out.push({ raw: trimmed, name, altNames: alt || null, categories });
}
return out;
}
/**
* Dry run — parse + classify lines without writing. Drives the preview UI.
*/
export async function previewActressImport(text: string): Promise<ImportResult> {
const parsed = parseLines(text);
const lines: ImportPreviewLine[] = [];
const existingCats = new Set(
(rawDb.prepare(`SELECT name FROM actress_categories`).all() as Array<{ name: string }>)
.map((r) => r.name.toLowerCase()),
);
// Dedupe new-category collection by lowercased key to match the
// commit path, which keys its catCache by toLowerCase(). Otherwise
// "Action" and "ACTION" in the input would be reported as 2 new
// categories in the preview but commit would create only 1.
const newCategoriesByKey = new Map<string, string>();
// Track within-input new actresses too: a name appearing twice should
// be counted once in the preview, matching the commit (which inserts
// the first and rejects/sees the second as existing).
const seenNewNames = new Set<string>();
let added = 0, skipped = 0, errors = 0;
for (const p of parsed) {
if (!p.name) {
lines.push({ ...p, status: "blank" });
continue;
}
const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined;
const nameKey = p.name.toLowerCase();
if (existing || seenNewNames.has(nameKey)) {
lines.push({ ...p, status: "exists" });
skipped++;
continue;
}
for (const c of p.categories) {
const key = c.toLowerCase();
if (!existingCats.has(key) && !newCategoriesByKey.has(key)) {
newCategoriesByKey.set(key, c);
}
}
seenNewNames.add(nameKey);
lines.push({ ...p, status: "new" });
added++;
}
return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesByKey.values()) };
}
/**
* Commit — actually write. Auto-creates unknown categories with palette colors.
*/
export async function commitActressImport(text: string, defaultCategoryIds: number[] = []): Promise<ImportResult> {
const parsed = parseLines(text);
const lines: ImportPreviewLine[] = [];
let added = 0, skipped = 0, errors = 0;
const newCategoriesSet = new Set<string>();
// Cache existing categories by lowercased name → id.
const catCache = new Map<string, number>();
for (const row of rawDb.prepare(`SELECT id, name FROM actress_categories`).all() as Array<{ id: number; name: string }>) {
catCache.set(row.name.toLowerCase(), row.id);
}
// Track NEW categories with their own counter so palette colors are
// assigned in import order — using catCache.size meant the first new
// category's color depended on the existing-category count and often
// collided with an existing category's chosen color.
let newCategoryCount = 0;
// Helper to get or create a category.
function getOrCreateCategory(name: string): number {
const key = name.toLowerCase();
const hit = catCache.get(key);
if (hit != null) return hit;
const slug = uniqueSlug(rawDb, "actress_categories", name);
const color = PALETTE[newCategoryCount++ % PALETTE.length];
const row = rawDb.prepare(`
INSERT INTO actress_categories (name, slug, color, icon, priority, builtin)
VALUES (?, ?, ?, 'tag', 50, 0) RETURNING id
`).get(name, slug, color) as { id: number };
catCache.set(key, row.id);
newCategoriesSet.add(name);
return row.id;
}
const tx = rawDb.transaction(() => {
for (const p of parsed) {
if (!p.name) {
lines.push({ ...p, status: "blank" });
continue;
}
const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined;
if (existing) {
lines.push({ ...p, status: "exists" });
skipped++;
continue;
}
try {
const slug = uniqueSlug(rawDb, "actresses", p.name);
const row = rawDb.prepare(`
INSERT INTO actresses (name, slug, alt_names) VALUES (?, ?, ?) RETURNING id
`).get(p.name, slug, p.altNames) as { id: number };
const assignedCatIds = new Set<number>();
for (const cat of p.categories) {
const catId = getOrCreateCategory(cat);
assignedCatIds.add(catId);
}
for (const id of defaultCategoryIds) assignedCatIds.add(id);
for (const id of assignedCatIds) {
rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(row.id, id);
}
lines.push({ ...p, status: "new" });
added++;
} catch (e) {
lines.push({ ...p, status: "error", reason: (e as Error).message });
errors++;
}
}
});
tx();
revalidatePath("/actress");
return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesSet) };
}