316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
|
|
import { closeDb, getDb } from "@/db/client";
|
|
import {
|
|
artifactGroups,
|
|
artifactProfiles,
|
|
artifactRecommendations,
|
|
catalogArtifacts,
|
|
catalogCharacters,
|
|
catalogWeapons,
|
|
constellationConditions,
|
|
constellationRecommendations,
|
|
extraSectionRows,
|
|
extraSections,
|
|
guides,
|
|
guideSections,
|
|
media,
|
|
outboxEvents,
|
|
teamCompositions,
|
|
teamMembers,
|
|
weaponRecommendations,
|
|
} from "@/db/schema";
|
|
import {
|
|
artifactSectionSchema,
|
|
constellationSectionSchema,
|
|
extraSectionSchema,
|
|
slugFromCharacterName,
|
|
teamSectionSchema,
|
|
weaponSectionSchema,
|
|
} from "@/lib/guides/schemas";
|
|
import {
|
|
buildGuideImportDraft,
|
|
guideImportSources,
|
|
type GuideImportDraft,
|
|
type ImportCatalog,
|
|
} from "@/lib/guides/google-sheet-import";
|
|
import { getMediaStorage } from "@/lib/media/storage";
|
|
import { hasValidImageSignature, MAX_MEDIA_BYTES } from "@/lib/media/validation";
|
|
|
|
const apply = process.argv.includes("--apply");
|
|
|
|
interface CoverAsset {
|
|
bytes: Uint8Array;
|
|
extension: "gif" | "jpeg" | "png" | "webp";
|
|
mimeType: "image/gif" | "image/jpeg" | "image/png" | "image/webp";
|
|
objectKey: string;
|
|
}
|
|
|
|
function coverType(bytes: Uint8Array): Pick<CoverAsset, "extension" | "mimeType"> | null {
|
|
const types = [
|
|
["png", "image/png"],
|
|
["jpeg", "image/jpeg"],
|
|
["webp", "image/webp"],
|
|
["gif", "image/gif"],
|
|
] as const;
|
|
for (const [extension, mimeType] of types) {
|
|
if (hasValidImageSignature(bytes.subarray(0, 16), mimeType)) return { extension, mimeType };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function downloadCover(draft: GuideImportDraft): Promise<CoverAsset> {
|
|
const response = await fetch(`https://buzz.sudloh.com/cdn/${draft.source.coverId}`, {
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!response.ok) throw new Error(`${draft.source.characterName}: cover returned ${response.status}`);
|
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw new Error(`${draft.source.characterName}: invalid cover size ${bytes.length}`);
|
|
const type = coverType(bytes);
|
|
if (!type) throw new Error(`${draft.source.characterName}: unsupported cover image`);
|
|
return {
|
|
...type,
|
|
bytes,
|
|
objectKey: `imports/buzz-guide/${draft.characterKey}/cover.${type.extension}`,
|
|
};
|
|
}
|
|
|
|
function validateDraft(draft: GuideImportDraft): void {
|
|
weaponSectionSchema.parse({ expectedVersion: 1, note: null, rows: draft.weapons });
|
|
artifactSectionSchema.parse({ expectedVersion: 1, ...draft.artifacts });
|
|
constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: draft.constellationGroups });
|
|
teamSectionSchema.parse({ expectedVersion: 1, teams: draft.teams });
|
|
extraSectionSchema.parse({
|
|
expectedVersion: 1,
|
|
sections: draft.extras.map((section) => ({
|
|
title: section.title,
|
|
rows: section.rows.map((row) => ({ ...row, mediaId: null, ratio: "16:9" as const })),
|
|
})),
|
|
});
|
|
}
|
|
|
|
function reportFor(draft: GuideImportDraft) {
|
|
return {
|
|
character: draft.source.characterName,
|
|
action: draft.source.skipWrite ? "keep-existing" : draft.source.characterName === "Columbina" ? "restore-or-update" : "create-or-update",
|
|
weapons: draft.weapons.length,
|
|
artifacts: draft.artifacts.groups.reduce((total, group) => total + group.rows.length, 0),
|
|
constellations: draft.constellationGroups.reduce((total, group) => total + group.rows.length, 0),
|
|
teams: draft.teams.length,
|
|
extras: draft.extras.reduce((total, section) => total + section.rows.length, 0),
|
|
skipped: [...new Set(draft.skipped)],
|
|
};
|
|
}
|
|
|
|
async function loadCatalog(): Promise<ImportCatalog> {
|
|
const db = getDb();
|
|
const [characters, weapons, artifacts] = await Promise.all([
|
|
db.select({ key: catalogCharacters.key, name: catalogCharacters.name, constellations: catalogCharacters.constellations }).from(catalogCharacters),
|
|
db.select({ key: catalogWeapons.key, name: catalogWeapons.name }).from(catalogWeapons),
|
|
db.select({ key: catalogArtifacts.key, name: catalogArtifacts.name }).from(catalogArtifacts),
|
|
]);
|
|
return { characters, weapons, artifacts };
|
|
}
|
|
|
|
async function uniqueSlug(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], name: string, key: string): Promise<string> {
|
|
const base = slugFromCharacterName(name, key);
|
|
const [collision] = await tx.select({ id: guides.id }).from(guides).where(eq(guides.slug, base)).limit(1);
|
|
return collision ? `${base}-${key.toLowerCase()}` : base;
|
|
}
|
|
|
|
async function replaceGuide(
|
|
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
|
|
draft: GuideImportDraft,
|
|
coverMediaId: string,
|
|
sortOrder: number,
|
|
): Promise<void> {
|
|
const [catalogCharacter] = await tx.select().from(catalogCharacters).where(eq(catalogCharacters.key, draft.characterKey)).limit(1);
|
|
if (!catalogCharacter) throw new Error(`${draft.source.characterName}: character disappeared from catalog`);
|
|
const [existing] = await tx.select().from(guides).where(eq(guides.characterKey, draft.characterKey)).limit(1);
|
|
let guideId: string;
|
|
let version: number;
|
|
if (existing) {
|
|
guideId = existing.id;
|
|
version = existing.version + 1;
|
|
await tx.update(guides).set({
|
|
overview: draft.source.overview,
|
|
coverMediaId,
|
|
sortOrder,
|
|
trashedAt: null,
|
|
version,
|
|
}).where(eq(guides.id, guideId));
|
|
if (existing.coverMediaId !== coverMediaId) {
|
|
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, existing.coverMediaId));
|
|
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId));
|
|
}
|
|
} else {
|
|
const slug = await uniqueSlug(tx, catalogCharacter.name, catalogCharacter.key);
|
|
const [created] = await tx.insert(guides).values({
|
|
characterKey: catalogCharacter.key,
|
|
slug,
|
|
name: catalogCharacter.name,
|
|
overview: draft.source.overview,
|
|
coverMediaId,
|
|
sortOrder,
|
|
}).returning();
|
|
guideId = created.id;
|
|
version = created.version;
|
|
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId));
|
|
}
|
|
|
|
await Promise.all([
|
|
tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId)),
|
|
tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)),
|
|
tx.delete(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)),
|
|
tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId)),
|
|
tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId)),
|
|
tx.delete(guideSections).where(eq(guideSections.guideId, guideId)),
|
|
]);
|
|
|
|
const defaultSections = [
|
|
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
|
|
{ kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 },
|
|
{ kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 },
|
|
{ kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 },
|
|
];
|
|
await tx.insert(guideSections).values(defaultSections.map((section) => ({ ...section, guideId })));
|
|
|
|
if (draft.weapons.length) {
|
|
await tx.insert(weaponRecommendations).values(draft.weapons.map((row, rowIndex) => ({
|
|
...row,
|
|
guideId,
|
|
sortOrder: rowIndex,
|
|
overallPercent: row.overallPercent?.toString() ?? null,
|
|
personalPercent: row.personalPercent?.toString() ?? null,
|
|
})));
|
|
}
|
|
for (const [groupIndex, group] of draft.artifacts.groups.entries()) {
|
|
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder: groupIndex }).returning();
|
|
if (group.rows.length) {
|
|
await tx.insert(artifactRecommendations).values(group.rows.map((row, rowIndex) => ({
|
|
...row,
|
|
groupId: created.id,
|
|
sortOrder: rowIndex,
|
|
percent: row.percent.toString(),
|
|
})));
|
|
}
|
|
}
|
|
await tx.insert(artifactProfiles).values({
|
|
guideId,
|
|
sands: draft.artifacts.sands,
|
|
goblet: draft.artifacts.goblet,
|
|
circlet: draft.artifacts.circlet,
|
|
substats: draft.artifacts.substats,
|
|
targets: draft.artifacts.targets,
|
|
talents: draft.artifacts.talents,
|
|
note: draft.artifacts.note,
|
|
});
|
|
for (const [groupIndex, group] of draft.constellationGroups.entries()) {
|
|
const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder: groupIndex }).returning();
|
|
if (group.rows.length) {
|
|
await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({
|
|
...row,
|
|
conditionId: created.id,
|
|
cumulativePercent: row.cumulativePercent.toString(),
|
|
})));
|
|
}
|
|
}
|
|
for (const [teamIndex, team] of draft.teams.entries()) {
|
|
const [created] = await tx.insert(teamCompositions).values({
|
|
guideId,
|
|
title: team.title,
|
|
rotationSeconds: team.rotationSeconds?.toString() ?? null,
|
|
combo: team.combo,
|
|
sortOrder: teamIndex,
|
|
}).returning();
|
|
await tx.insert(teamMembers).values(team.members.map((member, position) => ({
|
|
...member,
|
|
teamId: created.id,
|
|
position,
|
|
damage: member.damage.toString(),
|
|
})));
|
|
}
|
|
for (const [extraIndex, section] of draft.extras.entries()) {
|
|
const [createdSection] = await tx.insert(guideSections).values({
|
|
guideId,
|
|
kind: "extra",
|
|
slug: `extra-${extraIndex + 1}`,
|
|
title: section.title,
|
|
sortOrder: extraIndex + 4,
|
|
}).returning();
|
|
await tx.insert(extraSections).values({ sectionId: createdSection.id });
|
|
await tx.insert(extraSectionRows).values(section.rows.map((row, rowIndex) => ({
|
|
sectionId: createdSection.id,
|
|
heading: row.heading,
|
|
body: row.body,
|
|
percent: row.percent?.toString() ?? null,
|
|
ratio: "16:9",
|
|
sortOrder: rowIndex,
|
|
})));
|
|
}
|
|
|
|
await tx.insert(outboxEvents).values([
|
|
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
|
|
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
|
|
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
|
|
]);
|
|
}
|
|
|
|
async function applyDrafts(drafts: GuideImportDraft[]): Promise<void> {
|
|
const writable = drafts.filter((draft) => !draft.source.skipWrite);
|
|
const covers = new Map<string, CoverAsset>();
|
|
for (const draft of writable) covers.set(draft.characterKey, await downloadCover(draft));
|
|
const storage = await getMediaStorage();
|
|
for (const cover of covers.values()) {
|
|
await storage.write(cover.objectKey, cover.bytes, { type: cover.mimeType, acl: "public-read" });
|
|
}
|
|
|
|
const db = getDb();
|
|
const [{ maximum }] = await db.select({ maximum: sql<number>`coalesce(max(${guides.sortOrder}), 0)` }).from(guides).where(isNull(guides.trashedAt));
|
|
await db.transaction(async (tx) => {
|
|
for (const [index, draft] of writable.entries()) {
|
|
const cover = covers.get(draft.characterKey)!;
|
|
let [coverRecord] = await tx.select().from(media).where(eq(media.objectKey, cover.objectKey)).limit(1);
|
|
if (!coverRecord) {
|
|
[coverRecord] = await tx.insert(media).values({
|
|
objectKey: cover.objectKey,
|
|
fileName: `cover.${cover.extension}`,
|
|
mimeType: cover.mimeType,
|
|
byteSize: cover.bytes.length,
|
|
status: "ready",
|
|
}).returning();
|
|
} else {
|
|
[coverRecord] = await tx.update(media).set({
|
|
mimeType: cover.mimeType,
|
|
byteSize: cover.bytes.length,
|
|
status: "ready",
|
|
}).where(eq(media.id, coverRecord.id)).returning();
|
|
}
|
|
await replaceGuide(tx, draft, coverRecord.id, Number(maximum) + index + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const catalog = await loadCatalog();
|
|
const drafts: GuideImportDraft[] = [];
|
|
for (const source of guideImportSources) {
|
|
const draft = await buildGuideImportDraft(source, catalog);
|
|
validateDraft(draft);
|
|
drafts.push(draft);
|
|
}
|
|
console.log(JSON.stringify({ mode: apply ? "apply" : "dry-run", guides: drafts.map(reportFor) }, null, 2));
|
|
if (!apply) return;
|
|
await applyDrafts(drafts);
|
|
const sourceKeys = drafts.map((draft) => draft.characterKey);
|
|
const active = await getDb().select({ characterKey: guides.characterKey }).from(guides).where(and(inArray(guides.characterKey, sourceKeys), isNull(guides.trashedAt)));
|
|
if (active.length !== sourceKeys.length) throw new Error(`Expected ${sourceKeys.length} active source guides, found ${active.length}`);
|
|
console.log(`Imported ${drafts.filter((draft) => !draft.source.skipWrite).length} guides; ${active.length} source guides are active.`);
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} finally {
|
|
await closeDb();
|
|
}
|