Files
OnePieceDle/scripts/import-json.ts
T
Whidix d75c74ac3c Refactor character affiliations to singular form
- Updated character data structure to replace 'affiliations' and 'frAffiliations' with 'affiliation' and 'frAffiliation'.
- Modified related functions and components to accommodate the new structure.
- Adjusted database schema and server-side logic to reflect the changes in character affiliation handling.
- Ensured all references in the UI components and data import/export scripts are updated accordingly.
2026-04-14 21:56:26 +02:00

425 lines
12 KiB
TypeScript

import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { sql, eq, inArray } from 'drizzle-orm';
import fs from 'fs';
import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema';
type Status = 'Alive' | 'Dead' | 'Unknown';
type ArcRecord = {
id: string;
name: string;
frName?: string | null;
startChapter: number;
endChapter?: number | null;
url?: string | null;
};
type DevilFruitRecord = {
id: string;
name: string;
type?: DevilFruitType | string | null;
url?: string | null;
};
type CharacterRecord = {
id: string;
name: string;
frName?: string | null;
gender?: string | null;
age?: number | null;
affiliation?: string | null;
frAffiliation?: string | null;
devilFruitId?: string | null;
hakiObservation?: boolean;
hakiArmament?: boolean;
hakiConqueror?: boolean;
bounty?: number | null;
height?: number | null;
origin?: string | null;
frOrigin?: string | null;
firstAppearance?: number;
pictureUrl?: string | null;
epithets?: string[] | string | null;
frEpithets?: string[] | string | null;
status?: Status | null;
arcId?: string | null;
url?: string | null;
frUrl?: string | null;
};
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
const client = createClient({ url: DATABASE_URL });
const db = drizzle(client);
function readJsonFile<T>(path: string): T[] | null {
if (!fs.existsSync(path)) {
return null;
}
const content = fs.readFileSync(path, 'utf-8');
return JSON.parse(content) as T[];
}
function toNullable<T>(value: T | undefined | null | ''): T | null {
return value === undefined || value === null || value === '' ? null : value;
}
function toJsonArray(value: string[] | string | null | undefined): string[] | null {
if (Array.isArray(value)) {
return value.length > 0 ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
if (value.trim().startsWith('[')) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [value];
} catch {
return [value];
}
}
const splitValues = value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return splitValues.length > 0 ? splitValues : null;
}
return null;
}
function toDevilFruitType(value: DevilFruitType | string | null | undefined): DevilFruitType | null {
if (!value) return null;
if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Smile' || value === 'Unknown') {
return value;
}
return 'Unknown';
}
function toNumber(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null;
const num = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(num) ? null : num;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function logSqlOnError(statement: { sql: string; params: unknown[] } | null): void {
if (!statement) return;
console.error(` SQL: ${statement.sql}`);
console.error(` Params: ${JSON.stringify(statement.params)}`);
}
function transformCharacterData(item: CharacterRecord) {
return {
id: item.id,
name: item.name,
frName: toNullable(item.frName),
gender: toNullable(item.gender),
age: toNullable(item.age),
affiliation: toNullable(item.affiliation),
frAffiliation: toNullable(item.frAffiliation),
devilFruitId: toNullable(item.devilFruitId),
hakiObservation: !!item.hakiObservation,
hakiArmament: !!item.hakiArmament,
hakiConqueror: !!item.hakiConqueror,
bounty: item.bounty ?? 0,
height: toNumber(item.height as string | number | null),
origin: toNullable(item.origin),
frOrigin: toNullable(item.frOrigin),
firstAppearance: item.firstAppearance ?? 0,
pictureUrl: toNullable(item.pictureUrl),
epithets: toJsonArray(item.epithets),
frEpithets: toJsonArray(item.frEpithets),
status: toNullable(item.status),
arcId: toNullable(item.arcId),
url: toNullable(item.url),
frUrl: toNullable(item.frUrl),
isDeleted: false
};
}
async function isCharacterTableEmpty(): Promise<boolean> {
const result = await db.select({ count: sql<number>`COUNT(*)` }).from(character);
return result[0]?.count === 0;
}
async function importFromJson(): Promise<void> {
let totalSuccess = 0;
let totalErrors = 0;
try {
const arcs = readJsonFile<ArcRecord>('./scraped-data/arcs.json');
if (arcs) {
console.log('\n=== Importing Arcs ===\n');
console.log(`Found ${arcs.length} arcs\n`);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < arcs.length; i++) {
const item = arcs[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const query = db
.insert(arc)
.values({
id: item.id,
name: item.name,
frName: toNullable(item.frName),
startChapter: item.startChapter,
endChapter: toNullable(item.endChapter),
url: toNullable(item.url)
})
.onConflictDoUpdate({
target: arc.id,
set: {
name: item.name,
frName: toNullable(item.frName),
startChapter: item.startChapter,
endChapter: toNullable(item.endChapter),
url: toNullable(item.url)
}
});
lastSql = query.toSQL();
await query;
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${arcs.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at arc ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
console.log(`\n\n✓ Arcs imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠️ No arcs.json found, skipping...\n');
}
const fruits = readJsonFile<DevilFruitRecord>('./scraped-data/devil-fruits.json');
if (fruits) {
console.log('\n=== Importing Devil Fruits ===\n');
console.log(`Found ${fruits.length} devil fruits\n`);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < fruits.length; i++) {
const item = fruits[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const query = db
.insert(devilFruit)
.values({
id: item.id,
name: item.name,
type: toDevilFruitType(item.type),
url: toNullable(item.url)
})
.onConflictDoUpdate({
target: devilFruit.id,
set: {
name: item.name,
type: toDevilFruitType(item.type),
url: toNullable(item.url)
}
});
lastSql = query.toSQL();
await query;
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${fruits.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at devil fruit ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
console.log(`\n\n✓ Devil Fruits imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠️ No devil-fruits.json found, skipping...\n');
}
const characters = readJsonFile<CharacterRecord>('./scraped-data/characters.json');
if (characters) {
console.log('\n=== Importing Characters ===\n');
console.log(`Found ${characters.length} characters\n`);
const isEmpty = await isCharacterTableEmpty();
let successCount = 0;
let errorCount = 0;
if (isEmpty) {
// Populate empty character table
console.log('Characters table is empty, populating...\n');
for (let i = 0; i < characters.length; i++) {
const item = characters[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const data = transformCharacterData(item);
const query = db
.insert(character)
.values(data)
.onConflictDoUpdate({
target: character.id,
set: data
});
lastSql = query.toSQL();
await query;
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${characters.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at character ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
} else {
// Update scrapeValidation table
console.log('Characters table not empty, updating scrapeValidation table for changes...\n');
const scrapedCharacterIds: string[] = [];
for (let i = 0; i < characters.length; i++) {
const item = characters[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const selectQuery = db
.select()
.from(character)
.where(eq(character.id, item.id));
lastSql = selectQuery.toSQL();
scrapedCharacterIds.push(item.id);
const jsonData = transformCharacterData(item);
const upsertQuery = db
.insert(characterScrapeValidation)
.values(jsonData)
.onConflictDoUpdate({
target: characterScrapeValidation.id,
set: jsonData
});
lastSql = upsertQuery.toSQL();
await upsertQuery;
successCount++;
process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at character ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
// Fetch all characters from the character table and mark those absent from the
// scrape as deleted in scrape validation.
const allCharacters = await db.select({ id: character.id }).from(character);
const scrapedSet = new Set(scrapedCharacterIds);
const idsToMarkDeleted = allCharacters
.map((c) => c.id)
.filter((id) => !scrapedSet.has(id));
if (idsToMarkDeleted.length > 0) {
console.log(`\n⚠️ Marking ${idsToMarkDeleted.length} character(s) as deleted in scrape validation...`);
const deletedCharacterRows = await db
.select()
.from(character)
.where(inArray(character.id, idsToMarkDeleted));
for (const row of deletedCharacterRows) {
await db
.insert(characterScrapeValidation)
.values({
id: row.id,
name: row.name,
frName: row.frName,
gender: row.gender,
age: row.age,
affiliation: row.affiliation,
frAffiliation: row.frAffiliation,
devilFruitId: row.devilFruitId,
hakiObservation: row.hakiObservation,
hakiArmament: row.hakiArmament,
hakiConqueror: row.hakiConqueror,
bounty: row.bounty,
height: row.height,
origin: row.origin,
frOrigin: row.frOrigin,
firstAppearance: row.firstAppearance,
pictureUrl: row.pictureUrl,
epithets: row.epithets,
frEpithets: row.frEpithets,
status: row.status,
arcId: row.arcId,
url: row.url,
frUrl: row.frUrl,
isDeleted: true
})
.onConflictDoUpdate({
target: characterScrapeValidation.id,
set: { isDeleted: true }
});
}
}
}
console.log(`\n\n✓ Characters imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠️ No characters.json found, skipping...\n');
}
console.log(`\n=== Total Import Summary ===`);
console.log(` Total Success: ${totalSuccess}`);
console.log(` Total Errors: ${totalErrors}\n`);
} catch (error) {
console.error('✗ Import failed:', getErrorMessage(error));
process.exit(1);
} finally {
client.close();
}
}
importFromJson().catch((error) => {
console.error(getErrorMessage(error));
process.exit(1);
});