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.
This commit is contained in:
2026-04-14 21:56:26 +02:00
parent fa14156d82
commit d75c74ac3c
12 changed files with 1256 additions and 107 deletions

View File

@@ -0,0 +1,8 @@
ALTER TABLE `character` ADD `affiliation` text;--> statement-breakpoint
ALTER TABLE `character` ADD `fr_affiliation` text;--> statement-breakpoint
ALTER TABLE `character` DROP COLUMN `affiliations`;--> statement-breakpoint
ALTER TABLE `character` DROP COLUMN `fr_affiliations`;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` ADD `affiliation` text;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` ADD `fr_affiliation` text;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` DROP COLUMN `affiliations`;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` DROP COLUMN `fr_affiliations`;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1775950314114, "when": 1775950314114,
"tag": "0002_old_earthquake", "tag": "0002_old_earthquake",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1776195681488,
"tag": "0003_mixed_ben_grimm",
"breakpoints": true
} }
] ]
} }

View File

@@ -28,8 +28,8 @@ type CharacterRecord = {
frName?: string | null; frName?: string | null;
gender?: string | null; gender?: string | null;
age?: number | null; age?: number | null;
affiliations?: string[] | string | null; affiliation?: string | null;
frAffiliations?: string[] | string | null; frAffiliation?: string | null;
devilFruitId?: string | null; devilFruitId?: string | null;
hakiObservation?: boolean; hakiObservation?: boolean;
hakiArmament?: boolean; hakiArmament?: boolean;
@@ -123,8 +123,8 @@ function transformCharacterData(item: CharacterRecord) {
frName: toNullable(item.frName), frName: toNullable(item.frName),
gender: toNullable(item.gender), gender: toNullable(item.gender),
age: toNullable(item.age), age: toNullable(item.age),
affiliations: toJsonArray(item.affiliations), affiliation: toNullable(item.affiliation),
frAffiliations: toJsonArray(item.frAffiliations), frAffiliation: toNullable(item.frAffiliation),
devilFruitId: toNullable(item.devilFruitId), devilFruitId: toNullable(item.devilFruitId),
hakiObservation: !!item.hakiObservation, hakiObservation: !!item.hakiObservation,
hakiArmament: !!item.hakiArmament, hakiArmament: !!item.hakiArmament,
@@ -369,8 +369,8 @@ async function importFromJson(): Promise<void> {
frName: row.frName, frName: row.frName,
gender: row.gender, gender: row.gender,
age: row.age, age: row.age,
affiliations: row.affiliations, affiliation: row.affiliation,
frAffiliations: row.frAffiliations, frAffiliation: row.frAffiliation,
devilFruitId: row.devilFruitId, devilFruitId: row.devilFruitId,
hakiObservation: row.hakiObservation, hakiObservation: row.hakiObservation,
hakiArmament: row.hakiArmament, hakiArmament: row.hakiArmament,

View File

@@ -23,8 +23,8 @@ interface Character {
frOrigin: string | null; frOrigin: string | null;
devilFruitId: string | null; devilFruitId: string | null;
devilFruitUrl: string | null; devilFruitUrl: string | null;
affiliations: string[]; affiliation: string | null;
frAffiliations: string[] | null; frAffiliation: string | null;
bounty: number | null; bounty: number | null;
hakiObservation: boolean; hakiObservation: boolean;
hakiArmament: boolean; hakiArmament: boolean;
@@ -370,7 +370,7 @@ async function fetchAllCharacters(arcsList: Arc[]): Promise<Character[]> {
Age: data.age, Age: data.age,
Status: data.status, Status: data.status,
Epithets: data.epithets.join(', '), Epithets: data.epithets.join(', '),
Affiliations: data.affiliations.join(', '), Affiliation: data.affiliation,
DevilFruitId: data.devilFruitId, DevilFruitId: data.devilFruitId,
DevilFruitUrl: data.devilFruitUrl, DevilFruitUrl: data.devilFruitUrl,
HakiObservation: data.hakiObservation ? 'Yes' : 'No', HakiObservation: data.hakiObservation ? 'Yes' : 'No',
@@ -453,8 +453,8 @@ async function fetchCharacter(
// Extract age // Extract age
const age = extractAge($); const age = extractAge($);
// Extract affiliations // Extract affiliation
const affiliations = await extractAffiliations($, 'en'); const affiliation = await extractAffiliations($, 'en');
// Extract epithets // Extract epithets
const epithets = extractEpithets($); const epithets = extractEpithets($);
@@ -513,7 +513,7 @@ async function fetchCharacter(
let frName = frjsonData?.parse?.title || null; let frName = frjsonData?.parse?.title || null;
const frAffiliations = frjsonData const frAffiliation = frjsonData
? await extractAffiliations(cheerio.load(frjsonData.parse?.text?.['*'] || ''), 'fr') ? await extractAffiliations(cheerio.load(frjsonData.parse?.text?.['*'] || ''), 'fr')
: null; : null;
@@ -542,8 +542,8 @@ async function fetchCharacter(
frOrigin, frOrigin,
devilFruitId, devilFruitId,
devilFruitUrl, devilFruitUrl,
affiliations, affiliation,
frAffiliations, frAffiliation,
bounty, bounty,
hakiObservation, hakiObservation,
hakiArmament, hakiArmament,
@@ -591,15 +591,15 @@ function extractAge($: cheerio.CheerioAPI): number | null {
/** /**
* Extract affiliations from infobox * Extract affiliations from infobox
*/ */
async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise<string[]> { async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise<string | null> {
const div = $('[data-source="affiliation"] .pi-data-value'); const div = $('[data-source="affiliation"] .pi-data-value');
if (div.length === 0) return []; if (div.length === 0) return null;
const cleanedDiv = div.clone(); const cleanedDiv = div.clone();
cleanedDiv.find('sup').remove(); cleanedDiv.find('sup').remove();
const text = cleanedDiv.html(); const text = cleanedDiv.html();
if (!text) return []; if (!text) return null;
// Resolve affiliations from linked page titles. // Resolve affiliations from linked page titles.
const links = cleanedDiv.find('a').toArray(); const links = cleanedDiv.find('a').toArray();
@@ -624,14 +624,14 @@ async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise
const uniqueLinks = Array.from(new Set(linkValues.filter(Boolean))); const uniqueLinks = Array.from(new Set(linkValues.filter(Boolean)));
if (uniqueLinks.length > 0) { if (uniqueLinks.length > 0) {
return uniqueLinks; return uniqueLinks[0];
} }
} }
// Fallback to parsing text // Fallback to parsing text
const cleanText = text.replace(/<[^>]*>/g, '').trim(); const cleanText = text.replace(/<[^>]*>/g, '').trim();
const parts = cleanText.split(/\s*\n\s*|\s*;\s*|\s*,\s*/).filter(Boolean); const parts = cleanText.split(/\s*\n\s*|\s*;\s*|\s*,\s*/).filter(Boolean);
return parts.length > 0 ? parts : []; return parts.length > 0 ? parts[0] : null;
} }
/** /**
@@ -869,9 +869,8 @@ async function saveToCSV(characters: Character[]): Promise<void> {
status: c.status || '', status: c.status || '',
epithets: Array.isArray(c.epithets) ? c.epithets.join(', ') : c.epithets || '', epithets: Array.isArray(c.epithets) ? c.epithets.join(', ') : c.epithets || '',
devilFruitId: c.devilFruitId || '', devilFruitId: c.devilFruitId || '',
affiliations: Array.isArray(c.affiliations) affiliation: c.affiliation || '',
? c.affiliations.join(', ') frAffiliation: c.frAffiliation || '',
: c.affiliations || '',
bounty: c.bounty ?? 0, bounty: c.bounty ?? 0,
hakiObservation: c.hakiObservation ? 1 : 0, hakiObservation: c.hakiObservation ? 1 : 0,
hakiArmament: c.hakiArmament ? 1 : 0, hakiArmament: c.hakiArmament ? 1 : 0,

View File

@@ -8,7 +8,7 @@
export let columnVisibility: { export let columnVisibility: {
status?: boolean; status?: boolean;
gender?: boolean; gender?: boolean;
affiliations?: boolean; affiliation?: boolean;
devilFruitType?: boolean; devilFruitType?: boolean;
haki?: boolean; haki?: boolean;
bounty?: boolean; bounty?: boolean;
@@ -18,52 +18,6 @@
arc?: boolean; arc?: boolean;
}; };
function normalizeAffiliations(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) {
return [];
}
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
} catch {
return [];
}
}
return trimmed
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
return [];
}
function firstAffiliation(value: unknown): string | null {
const affiliations = normalizeAffiliations(value);
return affiliations.length > 0 ? affiliations[0] : null;
}
function hasMatchingPrimaryAffiliation(characterAffiliations: unknown, dailyAffiliations: unknown): boolean {
const characterPrimary = firstAffiliation(characterAffiliations);
const dailyPrimary = firstAffiliation(dailyAffiliations);
return characterPrimary === dailyPrimary;
}
$: isFrench = $language === 'fr'; $: isFrench = $language === 'fr';
function getDisplayName(character: CharacterWithRelations): string { function getDisplayName(character: CharacterWithRelations): string {
@@ -106,6 +60,14 @@
return character.arcName; return character.arcName;
} }
function getDislayAffiliation(character: CharacterWithRelations): string | null {
if (isFrench && typeof character.frAffiliation === 'string' && character.frAffiliation.length > 0) {
return character.frAffiliation;
}
return character.affiliation;
}
function hasMatchingArc(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean { function hasMatchingArc(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean {
return getDisplayArcName(characterEntry) === getDisplayArcName(dailyEntry); return getDisplayArcName(characterEntry) === getDisplayArcName(dailyEntry);
} }
@@ -156,7 +118,7 @@
</p> </p>
</div> </div>
{/if} {/if}
{#if columnVisibility.affiliations !== false} {#if columnVisibility.affiliation !== false}
<div <div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24" class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
> >
@@ -319,23 +281,15 @@
{/if} {/if}
<!-- Affiliations --> <!-- Affiliations -->
{#if columnVisibility.affiliations !== false} {#if columnVisibility.affiliation !== false}
<div <div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingPrimaryAffiliation(character.affiliations, dailyCharacter.affiliations) class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {getDislayAffiliation(character) === getDislayAffiliation(dailyCharacter)
? 'bg-emerald-600/90' ? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center overflow-hidden p-1 sm:p-2" : 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
> >
{#if firstAffiliation(character.affiliations)} <p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
<p {getDislayAffiliation(character) || $t.game.components.guessHistory.unknown}
class="w-full text-center text-[10px] leading-tight font-bold wrap-break-word whitespace-normal text-white sm:text-xs md:text-sm" </p>
>
{firstAffiliation(character.affiliations)}
</p>
{:else}
<p class="text-center text-xs font-bold text-slate-400 sm:text-sm md:text-base">
-
</p>
{/if}
</div> </div>
{/if} {/if}

View File

@@ -86,10 +86,7 @@
> >
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.affiliation}</p> <p class="text-sm font-medium text-amber-100">{$t.game.components.hints.affiliation}</p>
{#if showHintAffiliation} {#if showHintAffiliation}
{@const affiliations = typeof dailyCharacter.affiliations === 'string' <p class="mt-2 text-xs text-white font-semibold">{isFrench && dailyCharacter.frAffiliation ? dailyCharacter.frAffiliation : dailyCharacter.affiliation || $t.game.components.hints.unknown}</p>
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
: dailyCharacter.affiliations}
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || $t.game.components.hints.unknown}</p>
{:else if Math.max(0, 15 - selectedCharacters.length) > 0} {:else if Math.max(0, 15 - selectedCharacters.length) > 0}
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p> <p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
{:else} {:else}

View File

@@ -11,8 +11,8 @@ const characterWithRelationsSelect = {
frName: character.frName, frName: character.frName,
gender: character.gender, gender: character.gender,
age: character.age, age: character.age,
affiliations: character.affiliations, affiliation: character.affiliation,
frAffiliations: character.frAffiliations, frAffiliation: character.frAffiliation,
devilFruitId: character.devilFruitId, devilFruitId: character.devilFruitId,
devilFruitName: devilFruit.name, devilFruitName: devilFruit.name,
devilFruitType: devilFruit.type, devilFruitType: devilFruit.type,

View File

@@ -43,8 +43,8 @@ export const character = sqliteTable('character', {
frName: text('fr_name'), frName: text('fr_name'),
gender: text('gender'), gender: text('gender'),
age: integer('age'), age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(), affiliation: text('affiliation'),
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(), frAffiliation: text('fr_affiliation'),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id), devilFruitId: text('devil_fruit_id').references(() => devilFruit.id),
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false), hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false), hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
@@ -73,8 +73,8 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
frName: text('fr_name'), frName: text('fr_name'),
gender: text('gender'), gender: text('gender'),
age: integer('age'), age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(), affiliation: text('affiliation'),
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(), frAffiliation: text('fr_affiliation'),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }), devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false), hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false), hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),

View File

@@ -34,7 +34,8 @@ async function applyCharacterChangeFromScrapeValidation(characterId: string): Pr
frName: scraped.frName, frName: scraped.frName,
gender: scraped.gender, gender: scraped.gender,
age: scraped.age, age: scraped.age,
affiliations: scraped.affiliations, affiliation: scraped.affiliation,
frAffiliation: scraped.frAffiliation,
devilFruitId: scraped.devilFruitId, devilFruitId: scraped.devilFruitId,
hakiObservation: scraped.hakiObservation, hakiObservation: scraped.hakiObservation,
hakiArmament: scraped.hakiArmament, hakiArmament: scraped.hakiArmament,
@@ -59,7 +60,8 @@ async function applyCharacterChangeFromScrapeValidation(characterId: string): Pr
frName: scraped.frName, frName: scraped.frName,
gender: scraped.gender, gender: scraped.gender,
age: scraped.age, age: scraped.age,
affiliations: scraped.affiliations, affiliation: scraped.affiliation,
frAffiliation: scraped.frAffiliation,
devilFruitId: scraped.devilFruitId, devilFruitId: scraped.devilFruitId,
hakiObservation: scraped.hakiObservation, hakiObservation: scraped.hakiObservation,
hakiArmament: scraped.hakiArmament, hakiArmament: scraped.hakiArmament,
@@ -129,7 +131,8 @@ export async function load() {
'frName', 'frName',
'gender', 'gender',
'age', 'age',
'affiliations', 'affiliation',
'frAffiliation',
'devilFruitId', 'devilFruitId',
'hakiObservation', 'hakiObservation',
'hakiArmament', 'hakiArmament',

View File

@@ -26,7 +26,7 @@ export const load: PageServerLoad = async () => {
name: character.name, name: character.name,
gender: character.gender, gender: character.gender,
age: character.age, age: character.age,
affiliations: normalizeArray(character.affiliations), affiliation: character.affiliation,
devilFruitId: character.devilFruitId, devilFruitId: character.devilFruitId,
hakiObservation: character.hakiObservation, hakiObservation: character.hakiObservation,
hakiArmament: character.hakiArmament, hakiArmament: character.hakiArmament,

View File

@@ -44,7 +44,7 @@
bounty: 0, bounty: 0,
height: 0, height: 0,
origin: '', origin: '',
affiliations: '', affiliation: '',
epithets: '', epithets: '',
pictureUrl: '', pictureUrl: '',
url: '', url: '',
@@ -101,7 +101,7 @@
bounty: override.bounty ?? null, bounty: override.bounty ?? null,
height: override.height ?? null, height: override.height ?? null,
origin: override.origin ?? '', origin: override.origin ?? '',
affiliations: override.affiliations ?? '', affiliation: override.affiliation ?? '',
epithets: override.epithets ?? '', epithets: override.epithets ?? '',
pictureUrl: override.pictureUrl ?? '', pictureUrl: override.pictureUrl ?? '',
url: override.url ?? '', url: override.url ?? '',
@@ -127,7 +127,7 @@
bounty: 0, bounty: 0,
height: 0, height: 0,
origin: '', origin: '',
affiliations: '', affiliation: '',
epithets: '', epithets: '',
pictureUrl: '', pictureUrl: '',
url: '', url: '',
@@ -341,12 +341,8 @@
<td class="px-4 py-4 text-sm text-gray-400">{char.gender || '-'}</td> <td class="px-4 py-4 text-sm text-gray-400">{char.gender || '-'}</td>
<!-- Affiliations --> <!-- Affiliations -->
<td class="px-4 py-4 text-sm text-gray-400"> <td class="px-4 py-4 text-sm text-gray-400">
{#if char.affiliations} {#if char.affiliation}
{#if Array.isArray(char.affiliations) && char.affiliations.length > 0} {char.affiliation}
<span class="inline-block" title={char.affiliations.join(', ')}>{char.affiliations[0]}</span>
{:else}
{char.affiliations}
{/if}
{:else} {:else}
- -
{/if} {/if}