diff --git a/README.md b/README.md
index 1909f4b..92ba1eb 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,7 @@ and external services. Never enable it in a deployed environment.
| `bun run db:generate` | Generate a Drizzle migration from schema changes |
| `bun run db:migrate` | Apply committed Drizzle migrations |
| `bun run db:seed` | Upsert the built-in templates |
+| `bun run import:guides` | Preview the one-time Google Sheets guide migration (`-- --apply` to write) |
| `bun run worker:discord` | Listen for Lunaris update announcements |
| `bun run worker:outbox` | Process cache and event outbox records |
diff --git a/app/register/page.tsx b/app/register/page.tsx
index 14e7261..6259315 100644
--- a/app/register/page.tsx
+++ b/app/register/page.tsx
@@ -9,12 +9,12 @@ export default async function RegisterPage() {
await connection();
if (await getAdminSession()) redirect("/admin");
if (await !getAdminSession()) redirect("/");
- return ( null
- //
- //
- //
- //
- //
- //
+ return (
+
+
+
+
+
+
);
}
diff --git a/bun.lock b/bun.lock
index 83030f9..5da4240 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,6 +17,7 @@
"discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0",
+ "fflate": "^0.8.2",
"ioredis": "^6.0.0",
"lucide-react": "^1.35.0",
"modern-screenshot": "^4.7.0",
@@ -953,6 +954,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
+
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index c5bcba8..35a9553 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -148,6 +148,13 @@
"when": 1788411911516,
"tag": "0020_aberrant_dark_beast",
"breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "7",
+ "when": 1788420000000,
+ "tag": "0021_optional_team_rotation",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/lib/guides/google-sheet-import.test.ts b/lib/guides/google-sheet-import.test.ts
new file mode 100644
index 0000000..3abf680
--- /dev/null
+++ b/lib/guides/google-sheet-import.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ buildGuideImportDraft,
+ charactersFromCombo,
+ parseCsv,
+ type GuideImportSource,
+ type ImportCatalog,
+} from "@/lib/guides/google-sheet-import";
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("Google Sheet guide import", () => {
+ it("parses quoted commas, escaped quotes, and multiline cells", () => {
+ expect(parseCsv('"name","note"\n"A, B","line 1\nline ""2"""\n')).toEqual([
+ ["name", "note"],
+ ["A, B", 'line 1\nline "2"'],
+ ]);
+ });
+
+ it("builds supported compact fields and reports missing catalog rows", async () => {
+ const source: GuideImportSource = {
+ characterName: "Hero",
+ overview: "Guide Hero",
+ workbookId: "sheet-id",
+ coverId: "cover-id",
+ layout: "compact",
+ infoSheet: "Hero Info",
+ teamSheets: [],
+ comboSheets: [],
+ weaponMetric: "overall-personal",
+ };
+ const catalog: ImportCatalog = {
+ characters: [{ key: "hero", name: "Hero", constellations: [] }],
+ weapons: [{ key: "weapon", name: "Known Weapon" }],
+ artifacts: [{ key: "artifact", name: "Known Set" }],
+ };
+ const rows = Array.from({ length: 24 }, () => Array.from({ length: 21 }, () => ""));
+ rows[0][10] = "Weapons อาวุธที่แนะนำ";
+ rows[1][11] = "Known Weapon R5";
+ rows[1][13] = "110%";
+ rows[1][14] = "105%";
+ rows[3][11] = "Missing Weapon R1";
+ rows[3][13] = "100%";
+ rows[5][10] = "Artifacts อาร์ติแฟกต์ที่แนะนำ (เลทเกม)";
+ rows[6][11] = "4 Known Set";
+ rows[6][13] = "100%";
+ rows[8][5] = "ATK (พลังโจมตี)";
+ rows[8][6] = "2000+";
+ rows[8][7] = "สแตทที่แนะนำ เลทเกม (AR 45+)";
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n"))));
+
+ const draft = await buildGuideImportDraft(source, catalog);
+
+ expect(draft.weapons).toEqual([expect.objectContaining({ weaponKey: "weapon", refinement: 5, overallPercent: 110, personalPercent: 105 })]);
+ expect(draft.artifacts.groups[0].rows).toEqual([expect.objectContaining({ firstArtifactKey: "artifact", percent: 100 })]);
+ expect(draft.artifacts.targets).toEqual([{ stat: "ATK", value: "2000+" }]);
+ expect(draft.skipped).toContain("weapon not in catalog: Missing Weapon R1");
+ });
+
+ it("does not fetch or populate a keep-existing source", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ const draft = await buildGuideImportDraft({
+ characterName: "Yumemizuki Mizuki",
+ overview: "existing",
+ workbookId: "sheet-id",
+ coverId: "cover-id",
+ layout: "full",
+ infoSheet: "Mizuki Info",
+ teamSheets: [],
+ comboSheets: [],
+ skipWrite: true,
+ }, {
+ characters: [{ key: "mizuki", name: "Yumemizuki Mizuki", constellations: [] }],
+ weapons: [],
+ artifacts: [],
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(draft.weapons).toEqual([]);
+ });
+
+ it("resolves the unique characters named by a combo", () => {
+ const characters = ["Yae Miko", "Odette", "Alyosha", "Sandrone"].map((name, index) => ({
+ key: String(index),
+ name,
+ constellations: [],
+ }));
+ expect(charactersFromCombo("Yae EEE > Odette EE > Alyosha EQ > Sandrone 3CE Q", {
+ characters,
+ weapons: [],
+ artifacts: [],
+ }).map((item) => item.name)).toEqual(["Yae Miko", "Odette", "Alyosha", "Sandrone"]);
+ });
+});
diff --git a/lib/guides/google-sheet-import.ts b/lib/guides/google-sheet-import.ts
new file mode 100644
index 0000000..7707849
--- /dev/null
+++ b/lib/guides/google-sheet-import.ts
@@ -0,0 +1,1388 @@
+import { posix as path } from "node:path";
+
+import { unzipSync } from "fflate";
+import sharp from "sharp";
+
+export type SheetMatrix = string[][];
+
+export interface GuideImportSource {
+ characterName: string;
+ overview: string;
+ workbookId: string;
+ coverId: string;
+ layout: "full" | "compact";
+ infoSheet: string;
+ weaponMetric?: "overall-personal" | "personal-only" | "primary-only";
+ teamSheets: string[];
+ comboSheets: string[];
+ skipWrite?: boolean;
+}
+
+export interface ImportCatalogItem {
+ key: string;
+ name: string;
+ imageUrl?: string;
+}
+
+export interface ImportCatalogCharacter extends ImportCatalogItem {
+ constellations: Array<{ level: number; description: string }>;
+}
+
+export interface ImportCatalog {
+ characters: ImportCatalogCharacter[];
+ weapons: ImportCatalogItem[];
+ artifacts: ImportCatalogItem[];
+}
+
+interface WorkbookVisual {
+ characterKey?: string;
+ weaponKey?: string;
+ artifactKey?: string;
+}
+
+type WorkbookVisuals = Map;
+
+export interface GuideImportDraft {
+ source: GuideImportSource;
+ characterKey: string;
+ weapons: Array<{
+ weaponKey: string;
+ refinement: number;
+ overallPercent: number | null;
+ personalPercent: number | null;
+ note: string | null;
+ }>;
+ artifacts: {
+ groups: Array<{
+ name: "Late Game" | "Early Game";
+ rows: Array<{
+ firstArtifactKey: string;
+ secondArtifactKey: string | null;
+ percent: number;
+ note: string | null;
+ }>;
+ }>;
+ sands: string[];
+ goblet: string[];
+ circlet: string[];
+ substats: string[];
+ targets: Array<{ stat: string; value: string }>;
+ talents: Array<{
+ type: "NA" | "E" | "Q";
+ priority: number;
+ level?: string;
+ relationToNext?: "gt" | "gte" | "eq";
+ }>;
+ note: string | null;
+ };
+ constellationGroups: Array<{
+ note: string | null;
+ rows: Array<{
+ level: number;
+ detail: string;
+ cumulativePercent: number;
+ isGreat: boolean;
+ }>;
+ }>;
+ teams: Array<{
+ title: string;
+ rotationSeconds: number | null;
+ combo: string;
+ members: Array<{
+ characterKey: string;
+ weaponKey: string | null;
+ artifactKey: string | null;
+ secondArtifactKey: string | null;
+ constellation: number;
+ refinement: number;
+ damage: number;
+ }>;
+ }>;
+ extras: Array<{
+ title: string;
+ rows: Array<{
+ heading: string;
+ body: string;
+ percent: number | null;
+ }>;
+ }>;
+ skipped: string[];
+}
+
+export const guideImportSources: GuideImportSource[] = [
+ {
+ characterName: "Yumemizuki Mizuki",
+ overview: "[7.1] Full Guide Mizuki",
+ workbookId: "1bLEKBOTMZ6H3NmxEM7o32HQgNqcRBR0pUV-OIeACpXA",
+ coverId: "01a0387a-4313-7d9a-b2ff-38e9356ccd47",
+ layout: "full",
+ infoSheet: "Mizuki Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "overall-personal",
+ },
+ {
+ characterName: "Odette",
+ overview: "[7.0] Full Guide Odette",
+ workbookId: "1I7iuPbigKD6OZ5t4M9-tE5AvP-BMAu7iJB54NilP6-o",
+ coverId: "01a0040b-0f75-7b20-9a87-e6ec105bd803",
+ layout: "full",
+ infoSheet: "Odette Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: ["Combo (คอมโบ)"],
+ weaponMetric: "overall-personal",
+ },
+ {
+ characterName: "Yae Miko",
+ overview: "[7.0] Full Guide Yae Miko",
+ workbookId: "1kUIsa7UmNc4WbhgRvL99wr0i2pD1rezcr8nWndRibFc",
+ coverId: "019f3848-2d0d-7295-9d3c-ae78e73752d4",
+ layout: "full",
+ infoSheet: "Yae Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "overall-personal",
+ },
+ {
+ characterName: "Mavuika",
+ overview: "[6.7] Full Guide Mavuika",
+ workbookId: "1DQIREJUi0RKqIgTDThCbgkgLrwPCqun6fL1D1TswjiU",
+ coverId: "019eb0ef-0a48-7109-ad20-1d3d1d0685cc",
+ layout: "compact",
+ infoSheet: "Mavuika Info",
+ teamSheets: ["คอมโบแบบลิง Team Calc"],
+ comboSheets: ["Combo"],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Sandrone",
+ overview: "[6.7] Full Guide Sandrone",
+ workbookId: "1YqRlAlO_q5EyL38j4E5Z57qztxUBS5muEChS0qC0E8s",
+ coverId: "019e78b4-9b68-7a07-b1ce-75aaf020b54e",
+ layout: "compact",
+ infoSheet: "Sandrone Info",
+ teamSheets: ["Team Calc", "ชีต7"],
+ comboSheets: [],
+ weaponMetric: "overall-personal",
+ },
+ {
+ characterName: "Nicole",
+ overview: "[6.6] Nicole Guide",
+ workbookId: "1vacoQds_seHc-4d-4Nhy2LIwkbwrRaTkn3a1epdWDF8",
+ coverId: "019e31ca-98ba-792c-bbe4-b35e2ccb685e",
+ layout: "compact",
+ infoSheet: "Nicole Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Lohen",
+ overview: "[6.6] Mini Guide Lohen",
+ workbookId: "1TH_5ve6i-COCe5thV37Z8ZpXiWWoJdklfsJHG9sNmf4",
+ coverId: "019d86ab-bfd5-77ad-b15f-1f590724f932",
+ layout: "compact",
+ infoSheet: "Lohen Info",
+ teamSheets: ["Team Calc", "ชีต7"],
+ comboSheets: [],
+ weaponMetric: "overall-personal",
+ },
+ {
+ characterName: "Nefer",
+ overview: "[6.5] Nefer Guide",
+ workbookId: "1bABpC7CTRRZaJ7EDZnzBcNjTTKSZhZQhR_Ee42I7mW0",
+ coverId: "019d5413-370e-771a-b402-9d064f517e12",
+ layout: "compact",
+ infoSheet: "Nefer Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Linnea",
+ overview: "[6.5] Linnea Guide",
+ workbookId: "1GLRUNkJcWvLO6FdMt8wgazE1Yic7-mIryLnXIJczyNc",
+ coverId: "019d07c6-80eb-7cc1-bc32-275125b0694e",
+ layout: "compact",
+ infoSheet: "Linnea Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Varka",
+ overview: "[6.4] Varka Guide",
+ workbookId: "1wxN1vfbpSqQagJexiT5K0gvioP8Rg_qOuBr514JlNcQ",
+ coverId: "019d07c6-8614-7495-a7d7-dddfda786cf3",
+ layout: "compact",
+ infoSheet: "Varka Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Zibai",
+ overview: "[6.3] Zibai Guide",
+ workbookId: "1LZ0nN6tREKa32whGqHQSPTdM4KTX4CxA6sKY2PgzByA",
+ coverId: "019d07c6-83d7-7f56-a1f6-8d500deb17bb",
+ layout: "compact",
+ infoSheet: "Zibai Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Ineffa",
+ overview: "[6.3] Ineffa Guide",
+ workbookId: "1ZvZ76gmczMs_L6gcXLW5TgffU0JjQrCqR42aNPELiFs",
+ coverId: "019d07c6-8574-7e4b-8b9b-48e3c08be240",
+ layout: "compact",
+ infoSheet: "Ineffa Info",
+ teamSheets: ["Team Calc", "Team Calc No Columbina"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Columbina",
+ overview: "[6.3] Columbina Guide",
+ workbookId: "12Q2CBMvGgldysO2IENh6O6TXoVL_iqv0VTSve0QHz98",
+ coverId: "019d07c6-8792-7923-ac39-b2f16cebc40c",
+ layout: "compact",
+ infoSheet: "Columbina Info",
+ teamSheets: [
+ "Columbina SubDPSSupport | Nodkr",
+ "Columbina SubDPSSupport | Other",
+ "Columbina MainDPS (On-field)",
+ ],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Mona",
+ overview: "[6.3] Mona ไสยเวทย์ Guide",
+ workbookId: "1GlQWI7J79G6ezUoOwRWyn__TEdEzsd8oee3RGUYoZZA",
+ coverId: "019d07c6-7fd4-7156-8551-e25cc335022f",
+ layout: "compact",
+ infoSheet: "Mona Info",
+ teamSheets: ["Mona Support", "Mona On field"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Flins",
+ overview: "[6.3] Flins Guide",
+ workbookId: "19exQORI29IDvI29f064cgQgp4jtViY9Co0h-y2A0k9w",
+ coverId: "019d07c6-8314-779f-bfda-4fe82129b14e",
+ layout: "compact",
+ infoSheet: "Flins Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+ {
+ characterName: "Fischl",
+ overview: "[6.3] Fischl ไสยเวทย์ Guide",
+ workbookId: "10e1fXcfubIqljb2FFfltM6sAKE6whcHujkSpipW-I7Y",
+ coverId: "019d07c6-869e-72fd-a48c-3bd21688a8a9",
+ layout: "compact",
+ infoSheet: "Fischl Info",
+ teamSheets: ["Team Calc"],
+ comboSheets: [],
+ weaponMetric: "primary-only",
+ },
+];
+
+const itemAliases: Record = {
+ "4vv": "Viridescent Venerer",
+ vv: "Viridescent Venerer",
+ ttds: "Thrilling Tales of Dragon Slayers",
+ redhorn: "Redhorn Stonethresher",
+ wolfgrave: "Wolf's Gravestone",
+ wolfgravestone: "Wolf's Gravestone",
+ theunforge: "The Unforged",
+ primodialjade: "Primordial Jade Winged-Spear",
+ primordialjade: "Primordial Jade Winged-Spear",
+ primordialjader1: "Primordial Jade Winged-Spear",
+ primodialjadecutter: "Primordial Jade Cutter",
+ kaguraverity: "Kagura's Verity",
+ athounsandfloatingdreams: "A Thousand Floating Dreams",
+ dawingfrost: "Dawning Frost",
+ stellarwatch: "Starcaller's Watch",
+ nocturnecurtaincall: "Nocturne's Curtain Call",
+ thounsandblazingsuns: "A Thousand Blazing Suns",
+ thousandblazingsun: "A Thousand Blazing Suns",
+ thousandblazingsuns: "A Thousand Blazing Suns",
+ mightywolf: "Gest of the Mighty Wolf",
+ balladofthemightywolf: "Gest of the Mighty Wolf",
+ daybreakchronicles: "The Daybreak Chronicles",
+ eternalflow: "Tome of the Eternal Flow",
+ hunterpath: "Hunter's Path",
+ nightskyunveiling: "Night of the Sky's Unveiling",
+ nightoftheskys: "Night of the Sky's Unveiling",
+ marechaussehunter: "Marechaussee Hunter",
+ carvedfromrisingwinds: "A Day Carved From Rising Winds",
+ risingwinds: "A Day Carved From Rising Winds",
+ desertpavillionchronicle: "Desert Pavilion Chronicle",
+ aubadeofmorningstar: "Aubade of Morningstar and Moon",
+ calamity: "Calamity Queller",
+ beacon: "Beacon of the Reed Sea",
+ floatingdreams: "A Thousand Floating Dreams",
+ tidalshadow: "Tidal Shadow",
+ crimsonwitch: "Crimson Witch of Flames",
+ marechauseehunter: "Marechaussee Hunter",
+ noblesse: "Noblesse Oblige",
+ tenacity: "Tenacity of the Millelith",
+ gildeddream: "Gilded Dreams",
+ scarletproof: "Scarlet Proof",
+ resolution: "Resolution of Sojourner",
+ instructor: "Instructor",
+};
+
+const characterAliases: Record = {
+ mizuki: "Yumemizuki Mizuki",
+ cryomc: "Traveler Cryo",
+ anemomc: "Traveler Anemo",
+ geomc: "Traveler Geo",
+ dendromc: "Traveler Dendro",
+ hydromc: "Traveler Hydro",
+ pyromc: "Traveler Pyro",
+ electromc: "Traveler Electro",
+};
+
+function normalized(value: string): string {
+ return value
+ .normalize("NFKD")
+ .toLowerCase()
+ .replace(/\([^)]*\)/gu, " ")
+ .replace(/\br(?:efinement\s*)?[1-5](?:-[1-5])?\b/giu, " ")
+ .replace(/^\s*[24]\s+/u, "")
+ .replace(/[^a-z0-9]+/gu, "");
+}
+
+function cleanLabel(value: string): string {
+ return value
+ .split("\n")[0]
+ .trim()
+ .replace(/^\s*[24]\s+/u, "");
+}
+
+function resolveItem(
+ value: string,
+ items: ImportCatalogItem[],
+): ImportCatalogItem | null {
+ const source = normalized(cleanLabel(value));
+ const alias = itemAliases[source];
+ const wanted = normalized(alias ?? source);
+ const exact = items.find((item) => normalized(item.name) === wanted);
+ if (exact) return exact;
+ if (wanted.length < 5) return null;
+ const prefix = items.filter((item) => {
+ const candidate = normalized(item.name);
+ return candidate.startsWith(wanted) || wanted.startsWith(candidate);
+ });
+ return prefix.length === 1 ? prefix[0] : null;
+}
+
+function resolveCharacter(
+ value: string,
+ items: ImportCatalogCharacter[],
+): ImportCatalogCharacter | null {
+ const withoutBuild = cleanLabel(value)
+ .replace(/\s+C[0-6](?:\s+R[1-5])?.*$/iu, "")
+ .trim();
+ const source = normalized(withoutBuild);
+ const wanted = normalized(characterAliases[source] ?? source);
+ const exact = items.find((item) => normalized(item.name) === wanted);
+ if (exact) return exact;
+ if (wanted.length < 4) return null;
+ const prefix = items.filter((item) =>
+ normalized(item.name).startsWith(wanted),
+ );
+ return prefix.length === 1 ? prefix[0] : null;
+}
+
+function percent(value: string): number | null {
+ if (/^\s*-\s*%?\s*$/u.test(value)) return 0;
+ const match = /(-?\d+(?:\.\d+)?)\s*%/u.exec(value.replace(/,/gu, ""));
+ return match ? Number(match[1]) : null;
+}
+
+function numeric(value: string): number | null {
+ const cleaned = value.replace(/,/gu, "").trim();
+ if (!/^-?\d+(?:\.\d+)?$/u.test(cleaned)) return null;
+ return Number(cleaned);
+}
+
+function refinement(value: string): number {
+ const match = /\bR([1-5])(?:-[1-5])?\b/iu.exec(value);
+ return match ? Number(match[1]) : 1;
+}
+
+function noteFromLabel(value: string): string | null {
+ const lines = value
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+ return lines.length > 1 ? lines.slice(1).join("\n") : null;
+}
+
+function unique(values: T[]): T[] {
+ return [...new Set(values)];
+}
+
+export function parseCsv(value: string): SheetMatrix {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = "";
+ let quoted = false;
+ for (let index = 0; index < value.length; index += 1) {
+ const character = value[index];
+ if (quoted) {
+ if (character === '"' && value[index + 1] === '"') {
+ field += '"';
+ index += 1;
+ } else if (character === '"') quoted = false;
+ else field += character;
+ } else if (character === '"') quoted = true;
+ else if (character === ",") {
+ row.push(field);
+ field = "";
+ } else if (character === "\n") {
+ row.push(field.replace(/\r$/u, ""));
+ rows.push(row);
+ row = [];
+ field = "";
+ } else field += character;
+ }
+ if (field || row.length) {
+ row.push(field.replace(/\r$/u, ""));
+ rows.push(row);
+ }
+ return rows;
+}
+
+export async function fetchSheetCsv(
+ source: GuideImportSource,
+ sheet: string,
+ range: string,
+): Promise {
+ const url = new URL(
+ `https://docs.google.com/spreadsheets/d/${source.workbookId}/gviz/tq`,
+ );
+ url.searchParams.set("tqx", "out:csv");
+ url.searchParams.set("headers", "0");
+ url.searchParams.set("sheet", sheet);
+ url.searchParams.set("range", range);
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
+ if (!response.ok)
+ throw new Error(
+ `${source.characterName}: ${sheet} returned ${response.status}`,
+ );
+ const body = await response.text();
+ if (/^>();
+
+async function imageFingerprint(input: Uint8Array | ArrayBuffer): Promise {
+ return new Uint8Array(
+ await sharp(Buffer.from(input instanceof ArrayBuffer ? new Uint8Array(input) : input))
+ .flatten({ background: "#000" })
+ .resize(16, 16, { fit: "fill" })
+ .raw()
+ .toBuffer(),
+ );
+}
+
+function catalogFingerprint(item: ImportCatalogItem): Promise {
+ if (!item.imageUrl) return Promise.resolve(null);
+ const cached = assetFingerprintCache.get(item.imageUrl);
+ if (cached) return cached;
+ const loading = fetch(item.imageUrl, { signal: AbortSignal.timeout(30_000) })
+ .then(async (response) =>
+ response.ok ? imageFingerprint(await response.arrayBuffer()) : null,
+ )
+ .catch(() => null);
+ assetFingerprintCache.set(item.imageUrl, loading);
+ return loading;
+}
+
+function fingerprintDistance(left: Uint8Array, right: Uint8Array): number {
+ let total = 0;
+ for (let index = 0; index < left.length; index += 1) {
+ const difference = left[index] - right[index];
+ total += difference * difference;
+ }
+ return Math.sqrt(total / left.length);
+}
+
+async function matchCatalogImage(
+ fingerprint: Uint8Array,
+ items: T[],
+): Promise {
+ const candidates = (
+ await Promise.all(
+ items.map(async (item) => ({ item, value: await catalogFingerprint(item) })),
+ )
+ )
+ .filter(
+ (candidate): candidate is { item: T; value: Uint8Array } =>
+ candidate.value !== null,
+ )
+ .map((candidate) => ({
+ item: candidate.item,
+ distance: fingerprintDistance(fingerprint, candidate.value),
+ }))
+ .sort((left, right) => left.distance - right.distance);
+ const best = candidates[0];
+ const runnerUp = candidates[1];
+ return best &&
+ best.distance <= 25 &&
+ (!runnerUp || runnerUp.distance - best.distance >= 5)
+ ? best.item
+ : null;
+}
+
+function xmlValue(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll(""", '"')
+ .replaceAll("'", "'")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+function xmlAttributes(tag: string): Record {
+ return Object.fromEntries(
+ [...tag.matchAll(/([\w:.-]+)="([^"]*)"/gu)].map((match) => [
+ match[1],
+ xmlValue(match[2]),
+ ]),
+ );
+}
+
+function archiveText(
+ files: Record,
+ fileName: string,
+): string | null {
+ const value = files[fileName];
+ return value ? new TextDecoder().decode(value) : null;
+}
+
+function relationshipMap(xml: string): Map {
+ return new Map(
+ [...xml.matchAll(/]*>/gu)].map((match) => {
+ const attributes = xmlAttributes(match[0]);
+ return [attributes.Id, attributes.Target];
+ }),
+ );
+}
+
+function relatedPath(source: string, target: string): string {
+ return path.normalize(path.join(path.dirname(source), target));
+}
+
+async function fetchWorkbookVisuals(
+ source: GuideImportSource,
+ catalog: ImportCatalog,
+): Promise {
+ if (
+ !source.teamSheets.length ||
+ !catalog.characters.some((item) => item.imageUrl)
+ )
+ return new Map();
+ const response = await fetch(
+ `https://docs.google.com/spreadsheets/d/${source.workbookId}/export?format=xlsx`,
+ { signal: AbortSignal.timeout(60_000) },
+ );
+ if (!response.ok)
+ throw new Error(`${source.characterName}: workbook returned ${response.status}`);
+ const files = unzipSync(new Uint8Array(await response.arrayBuffer()));
+ const workbookPath = "xl/workbook.xml";
+ const workbook = archiveText(files, workbookPath);
+ const workbookRelationships = archiveText(
+ files,
+ "xl/_rels/workbook.xml.rels",
+ );
+ if (!workbook || !workbookRelationships) return new Map();
+ const worksheetById = relationshipMap(workbookRelationships);
+ const visuals: WorkbookVisuals = new Map();
+ const imageMatches = new Map>();
+
+ for (const sheetTag of workbook.match(/]*>/gu) ?? []) {
+ const sheet = xmlAttributes(sheetTag);
+ if (!source.teamSheets.includes(sheet.name)) continue;
+ const worksheetTarget = worksheetById.get(sheet["r:id"]);
+ if (!worksheetTarget) continue;
+ const worksheetPath = relatedPath(workbookPath, worksheetTarget);
+ const worksheet = archiveText(files, worksheetPath);
+ const worksheetRelsPath = path.join(
+ path.dirname(worksheetPath),
+ "_rels",
+ `${path.basename(worksheetPath)}.rels`,
+ );
+ const worksheetRelationships = archiveText(files, worksheetRelsPath);
+ if (!worksheet || !worksheetRelationships) continue;
+ const worksheetRelationshipMap = relationshipMap(worksheetRelationships);
+ const drawingId = /]*r:id="([^"]+)"/u.exec(worksheet)?.[1];
+ const drawingTarget = drawingId
+ ? worksheetRelationshipMap.get(drawingId)
+ : null;
+ if (!drawingTarget) continue;
+ const drawingPath = relatedPath(worksheetPath, drawingTarget);
+ const drawing = archiveText(files, drawingPath);
+ const drawingRelsPath = path.join(
+ path.dirname(drawingPath),
+ "_rels",
+ `${path.basename(drawingPath)}.rels`,
+ );
+ const drawingRelationships = archiveText(files, drawingRelsPath);
+ if (!drawing || !drawingRelationships) continue;
+ const drawingRelationshipMap = relationshipMap(drawingRelationships);
+ for (const anchorMatch of drawing.matchAll(
+ /([\s\S]*?)<\/xdr:(?:one|two)CellAnchor>/gu,
+ )) {
+ const anchor = anchorMatch[1];
+ const column = Number(/(\d+)<\/xdr:col>/u.exec(anchor)?.[1]);
+ const row = Number(/(\d+)<\/xdr:row>/u.exec(anchor)?.[1]);
+ const imageId = /r:embed="([^"]+)"/u.exec(anchor)?.[1];
+ const imageTarget = imageId ? drawingRelationshipMap.get(imageId) : null;
+ if (!Number.isInteger(column) || !Number.isInteger(row) || !imageTarget)
+ continue;
+ const imagePath = relatedPath(drawingPath, imageTarget);
+ const image = files[imagePath];
+ if (!image) continue;
+ let matched = imageMatches.get(imagePath);
+ if (!matched) {
+ matched = imageFingerprint(image).then(async (fingerprint) => {
+ const [character, weapon, artifact] = await Promise.all([
+ matchCatalogImage(fingerprint, catalog.characters),
+ matchCatalogImage(fingerprint, catalog.weapons),
+ matchCatalogImage(fingerprint, catalog.artifacts),
+ ]);
+ return {
+ ...(character ? { characterKey: character.key } : {}),
+ ...(weapon ? { weaponKey: weapon.key } : {}),
+ ...(artifact ? { artifactKey: artifact.key } : {}),
+ };
+ });
+ imageMatches.set(imagePath, matched);
+ }
+ visuals.set(`${sheet.name}\0${row}\0${column}`, await matched);
+ }
+ }
+ return visuals;
+}
+
+function emptyDraft(
+ source: GuideImportSource,
+ characterKey: string,
+): GuideImportDraft {
+ return {
+ source,
+ characterKey,
+ weapons: [],
+ artifacts: {
+ groups: [
+ { name: "Late Game", rows: [] },
+ { name: "Early Game", rows: [] },
+ ],
+ sands: [],
+ goblet: [],
+ circlet: [],
+ substats: [],
+ targets: [],
+ talents: [],
+ note: null,
+ },
+ constellationGroups: [],
+ teams: [],
+ extras: [],
+ skipped: [],
+ };
+}
+
+function addWeapon(
+ draft: GuideImportDraft,
+ catalog: ImportCatalog,
+ label: string,
+ overall: string,
+ personal: string,
+ metric: GuideImportSource["weaponMetric"],
+): void {
+ if (!cleanLabel(label)) return;
+ const primaryPercent = percent(overall);
+ if (primaryPercent === null || /ชื่ออาวุธ/u.test(label)) return;
+ const weapon = resolveItem(label, catalog.weapons);
+ if (!weapon) {
+ draft.skipped.push(`weapon not in catalog: ${cleanLabel(label)}`);
+ return;
+ }
+ const secondary = percent(personal);
+ const overallPercent = metric === "personal-only" ? null : primaryPercent;
+ const personalPercent =
+ metric === "personal-only"
+ ? primaryPercent
+ : metric === "overall-personal"
+ ? secondary
+ : null;
+ const notes = [noteFromLabel(label)];
+ if (metric !== "overall-personal" && secondary !== null)
+ notes.push(`ค่าคอลัมน์ที่สองจากชีต: ${secondary}%`);
+ draft.weapons.push({
+ weaponKey: weapon.key,
+ refinement: refinement(label),
+ overallPercent,
+ personalPercent,
+ note: notes.filter(Boolean).join("\n") || null,
+ });
+}
+
+function compactWeaponMetric(matrix: SheetMatrix): GuideImportSource["weaponMetric"] {
+ const weaponHeader = matrix
+ .slice(0, 3)
+ .flat()
+ .find((cell) => /Weapons\s+อาวุธ/iu.test(cell));
+ if (!/ดาเมจส่วนตัว|personal\s*(?:damage|dmg)/iu.test(weaponHeader ?? ""))
+ return "primary-only";
+ return matrix.some(
+ (row) => percent(row[14] ?? "") !== null || percent(row[20] ?? "") !== null,
+ )
+ ? "overall-personal"
+ : "personal-only";
+}
+
+function artifactParts(label: string): string[] {
+ const cleaned = cleanLabel(label);
+ if (/^(?:2\s*)?EM\s*\+\s*2\s*EM$/iu.test(cleaned))
+ return ["Gilded Dreams", "Wanderer's Troupe"];
+ if (/^(?:2\s*)?ATK\s*\+\s*2\s*ATK$/iu.test(cleaned))
+ return ["Gladiator's Finale", "Shimenawa's Reminiscence"];
+ if (/^Berserker\s*\+\s*2\s*ATK$/iu.test(cleaned))
+ return ["Berserker", "Gladiator's Finale"];
+ return cleaned
+ .split(/\s*\+\s*/u)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function addArtifact(
+ draft: GuideImportDraft,
+ catalog: ImportCatalog,
+ groupIndex: 0 | 1,
+ label: string,
+ metric: string,
+): void {
+ if (!cleanLabel(label)) return;
+ const value = percent(metric);
+ if (value === null || /ชื่ออาร์ติแฟกต์/u.test(label)) return;
+ const parts = artifactParts(label);
+ if (!parts.length || parts.length > 2) {
+ draft.skipped.push(
+ `artifact combination cannot be represented: ${cleanLabel(label)}`,
+ );
+ return;
+ }
+ const resolved = parts.map((part) => resolveItem(part, catalog.artifacts));
+ if (resolved.some((item) => !item)) {
+ draft.skipped.push(`artifact not in catalog: ${cleanLabel(label)}`);
+ return;
+ }
+ draft.artifacts.groups[groupIndex].rows.push({
+ firstArtifactKey: resolved[0]!.key,
+ secondArtifactKey: resolved[1]?.key ?? null,
+ percent: value,
+ note: noteFromLabel(label),
+ });
+}
+
+const targetAliases: Record = {
+ atk: "ATK",
+ hp: "HP",
+ def: "DEF",
+ em: "EM",
+ er: "ER",
+ cr: "CRIT RATE",
+ cdm: "CRIT DAMAGE",
+};
+
+function targetName(value: string): string | null {
+ const match = /\b(ATK|HP|DEF|EM|ER|CR|CDM)\b/iu.exec(value);
+ return match ? targetAliases[match[1].toLowerCase()] : null;
+}
+
+function mainStat(value: string): string | null {
+ const text = value.trim();
+ const map: Array<[RegExp, string]> = [
+ [/ชำนาญธาตุ|^EM$/iu, "EM"],
+ [/ฟื้นฟูพลังงาน|^ER$/iu, "ER"],
+ [/พลังโจมตี|^ATK%$/iu, "ATK%"],
+ [/พลังชีวิต|^HP%$/iu, "HP%"],
+ [/พลังป้องกัน|^DEF%$/iu, "DEF%"],
+ [/อัตราคริ|แรงคริ|CRIT/iu, "CRIT Rate%/CRIT DMG%"],
+ [/โบนัสการรักษา|Healing/iu, "Healing Bonus%"],
+ ];
+ return map.find(([pattern]) => pattern.test(text))?.[1] ?? null;
+}
+
+function validSlotStat(
+ slot: "sands" | "goblet" | "circlet",
+ value: string,
+): boolean {
+ const options = {
+ sands: ["EM", "ER", "ATK%", "HP%", "DEF%"],
+ goblet: [
+ "EM",
+ "ATK%",
+ "DEF%",
+ "HP%",
+ "Pyro DMG%",
+ "Hydro DMG%",
+ "Cryo DMG%",
+ "Electro DMG%",
+ "Anemo DMG%",
+ "Geo DMG%",
+ "Dendro DMG%",
+ "Physical DMG%",
+ ],
+ circlet: [
+ "HP%",
+ "ATK%",
+ "DEF%",
+ "EM",
+ "CRIT Rate%/CRIT DMG%",
+ "Healing Bonus%",
+ ],
+ } as const;
+ return (options[slot] as readonly string[]).includes(value);
+}
+
+function talentType(value: string): "NA" | "E" | "Q" | null {
+ if (/การโจมตีปกติ|normal/iu.test(value)) return "NA";
+ if (/สกิลธาตุ|elemental skill/iu.test(value)) return "E";
+ if (/ท่าไม้ตาย|elemental burst/iu.test(value)) return "Q";
+ return null;
+}
+
+function talentLevel(value: string): string | undefined {
+ const match = /(?:LV\.?\s*)?([1-9]|10)/iu.exec(value);
+ return match?.[1];
+}
+
+function parseFullInfo(
+ matrix: SheetMatrix,
+ draft: GuideImportDraft,
+ catalog: ImportCatalog,
+): void {
+ for (const row of matrix) {
+ for (const [labelColumn, metricStart, metricEnd] of [
+ [4, 20, 30],
+ [34, 50, 60],
+ ] as const) {
+ const metrics = row
+ .slice(metricStart, metricEnd)
+ .filter((cell) => percent(cell) !== null);
+ addWeapon(
+ draft,
+ catalog,
+ row[labelColumn] ?? "",
+ metrics[0] ?? "",
+ metrics[1] ?? "",
+ "overall-personal",
+ );
+ }
+ }
+ let early = false;
+ for (const row of matrix) {
+ if (row.some((cell) => /ต้นเกม/u.test(cell))) early = true;
+ addArtifact(draft, catalog, early ? 1 : 0, row[4] ?? "", row[16] ?? "");
+ }
+ for (const row of matrix) {
+ const slots = [22, 28, 34] as const;
+ for (const [index, column] of slots.entries()) {
+ const value = mainStat(row[column] ?? "");
+ if (!value) continue;
+ const key = (["sands", "goblet", "circlet"] as const)[index];
+ if (validSlotStat(key, value)) draft.artifacts[key].push(value);
+ }
+ const target = targetName(row[22] ?? "");
+ if (target && row[28]?.trim())
+ draft.artifacts.targets.push({ stat: target, value: row[28].trim() });
+ }
+ const allCells = matrix.flat();
+ const substatCell = allCells.find(
+ (cell) => />/u.test(cell) && mainStat(cell),
+ );
+ if (substatCell)
+ draft.artifacts.substats = unique(
+ substatCell
+ .split(/\s*>\s*/u)
+ .map(mainStat)
+ .filter((item): item is string => Boolean(item)),
+ );
+ for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) {
+ for (
+ let column = 0;
+ column < (matrix[rowIndex]?.length ?? 0);
+ column += 1
+ ) {
+ const type = talentType(matrix[rowIndex][column]);
+ if (!type) continue;
+ const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? "");
+ draft.artifacts.talents.push({
+ type,
+ priority: draft.artifacts.talents.length + 1,
+ ...(level ? { level } : {}),
+ });
+ }
+ }
+}
+
+function parseCompactInfo(
+ matrix: SheetMatrix,
+ draft: GuideImportDraft,
+ catalog: ImportCatalog,
+): void {
+ const metric = compactWeaponMetric(matrix);
+ const artifactStart = matrix.findIndex((row) =>
+ row.some((cell) => /Artifacts\s+อาร์ติแฟกต์/iu.test(cell)),
+ );
+ for (const row of matrix.slice(
+ 0,
+ artifactStart >= 0 ? artifactStart : undefined,
+ )) {
+ addWeapon(
+ draft,
+ catalog,
+ row[11] ?? "",
+ row[13] ?? "",
+ row[14] ?? "",
+ metric,
+ );
+ addWeapon(
+ draft,
+ catalog,
+ row[17] ?? "",
+ row[19] ?? "",
+ row[20] ?? "",
+ metric,
+ );
+ }
+ if (artifactStart >= 0) {
+ const extraRows: GuideImportDraft["extras"][number]["rows"] = [];
+ for (const row of matrix.slice(artifactStart)) {
+ addArtifact(draft, catalog, 0, row[11] ?? "", row[13] ?? "");
+ addArtifact(draft, catalog, 1, row[17] ?? "", row[19] ?? "");
+ for (const [labelColumn, detailColumn] of [
+ [11, 13],
+ [17, 19],
+ ] as const) {
+ const label = cleanLabel(row[labelColumn] ?? "");
+ const detail = row[detailColumn]?.trim() ?? "";
+ if (
+ label &&
+ detail &&
+ !/ชื่ออาร์ติแฟกต์/u.test(label) &&
+ percent(detail) === null
+ ) {
+ extraRows.push({
+ heading: label.slice(0, 200),
+ body: detail.slice(0, 2_000),
+ percent: null,
+ });
+ }
+ }
+ }
+ if (extraRows.length)
+ draft.extras.push({
+ title: "คำแนะนำอาร์ติแฟกต์เพิ่มเติม",
+ rows: extraRows.slice(0, 50),
+ });
+ }
+
+ const targetGroups: Array<{
+ descriptor: string;
+ rows: Array<{ stat: string; value: string }>;
+ }> = [];
+ let current: {
+ descriptor: string;
+ rows: Array<{ stat: string; value: string }>;
+ } | null = null;
+ for (const row of matrix) {
+ const stat = targetName(row[5] ?? "");
+ const value = row[6]?.trim();
+ if (!stat || !value) continue;
+ if (!current || current.rows.some((item) => item.stat === stat)) {
+ current = { descriptor: row[7]?.trim() ?? "", rows: [] };
+ targetGroups.push(current);
+ }
+ if (row[7]?.trim()) current.descriptor = row[7].trim();
+ current.rows.push({ stat, value });
+ }
+ const primary =
+ targetGroups.find((group) => /เลทเกม|AR\s*45/iu.test(group.descriptor)) ??
+ targetGroups.at(-1);
+ if (primary) draft.artifacts.targets = primary.rows;
+ const alternateRows = targetGroups
+ .filter((group) => group !== primary)
+ .map((group, index) => ({
+ heading: group.descriptor || `สแตททางเลือก ${index + 1}`,
+ body: group.rows.map((item) => `${item.stat}: ${item.value}`).join("\n"),
+ percent: null,
+ }));
+ if (alternateRows.length)
+ draft.extras.push({ title: "สแตทสำหรับแนวทางอื่น", rows: alternateRows });
+
+ for (let rowIndex = 0; rowIndex < matrix.length; rowIndex += 1) {
+ const row = matrix[rowIndex];
+ const bestIndex = row.findIndex((cell) =>
+ /ออฟหลักที่ดีที่สุด/iu.test(cell),
+ );
+ if (bestIndex >= 0) {
+ const values = row
+ .slice(bestIndex + 1, bestIndex + 5)
+ .map(mainStat)
+ .filter((item): item is string => Boolean(item));
+ if (values[0] && validSlotStat("sands", values[0]))
+ draft.artifacts.sands.push(values[0]);
+ if (values[1] && validSlotStat("goblet", values[1]))
+ draft.artifacts.goblet.push(values[1]);
+ if (values[2] && validSlotStat("circlet", values[2]))
+ draft.artifacts.circlet.push(values[2]);
+ }
+ const offIndex = row.findIndex((cell) => /ออฟรอง/u.test(cell));
+ if (offIndex >= 0) {
+ const value = row.slice(offIndex + 1).find((cell) => />/u.test(cell));
+ if (value)
+ draft.artifacts.substats = unique(
+ value
+ .split(/\s*>\s*/u)
+ .map(mainStat)
+ .filter((item): item is string => Boolean(item)),
+ );
+ }
+ for (let column = 0; column < row.length; column += 1) {
+ const type = talentType(row[column]);
+ if (!type) continue;
+ const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? "");
+ draft.artifacts.talents.push({
+ type,
+ priority: draft.artifacts.talents.length + 1,
+ ...(level ? { level } : {}),
+ });
+ }
+ }
+
+ const constellationStart = matrix.findIndex((row) =>
+ row.some((cell) => /Constellation\s+กลุ่มดาว/iu.test(cell)),
+ );
+ const talentStart = matrix.findIndex(
+ (row, index) =>
+ index > constellationStart &&
+ row.some((cell) => /Talent\s+พรสวรรค์/iu.test(cell)),
+ );
+ if (constellationStart >= 0) {
+ const rows: GuideImportDraft["constellationGroups"][number]["rows"] = [];
+ const character = catalog.characters.find(
+ (item) => item.key === draft.characterKey,
+ )!;
+ for (const row of matrix.slice(
+ constellationStart + 1,
+ talentStart > 0 ? talentStart : undefined,
+ )) {
+ const levelCell = row.find((cell) => /^C[0-6]$/iu.test(cell.trim()));
+ const explicitLevel = levelCell
+ ? Number(/\d/u.exec(levelCell)?.[0])
+ : null;
+ if (explicitLevel === 0) continue;
+ const metrics = row
+ .map(percent)
+ .filter((item): item is number => item !== null);
+ if (!metrics.length || rows.length >= 6) continue;
+ const level = explicitLevel ?? rows.length + 1;
+ if (level < 1 || level > 6 || rows.some((item) => item.level === level))
+ continue;
+ const previous = rows.at(-1)?.cumulativePercent ?? 100;
+ rows.push({
+ level,
+ detail:
+ character.constellations.find((item) => item.level === level)
+ ?.description || `C${level}`,
+ cumulativePercent: metrics[0],
+ isGreat: metrics[0] - previous >= 10,
+ });
+ }
+ if (rows.length)
+ draft.constellationGroups.push({ note: "C0 = 100%", rows });
+ }
+}
+
+function parseMember(
+ value: string,
+ catalog: ImportCatalog,
+): { characterKey: string; constellation: number; refinement: number } | null {
+ const character = resolveCharacter(value, catalog.characters);
+ if (!character) return null;
+ return {
+ characterKey: character.key,
+ constellation: Number(/\bC([0-6])\b/iu.exec(value)?.[1] ?? 0),
+ refinement: refinement(value),
+ };
+}
+
+export function charactersFromCombo(
+ value: string,
+ catalog: ImportCatalog,
+): ImportCatalogCharacter[] {
+ const found: ImportCatalogCharacter[] = [];
+ for (const segment of value.split(/\s*>\s*/u)) {
+ const cleaned = segment.trim();
+ const matches = catalog.characters.filter((item) => {
+ const full = item.name.toLowerCase();
+ const first = full.split(/\s+/u)[0];
+ const source = cleaned.toLowerCase();
+ return (
+ source.startsWith(full) ||
+ (first.length >= 3 && source.startsWith(first))
+ );
+ });
+ if (
+ matches.length === 1 &&
+ !found.some((item) => item.key === matches[0].key)
+ )
+ found.push(matches[0]);
+ }
+ return found;
+}
+
+function parseTeams(
+ matrix: SheetMatrix,
+ draft: GuideImportDraft,
+ catalog: ImportCatalog,
+ sheetName: string,
+): void {
+ for (let rowIndex = 0; rowIndex < matrix.length - 4; rowIndex += 1) {
+ const row = matrix[rowIndex];
+ for (let start = 0; start < row.length; start += 1) {
+ const labels = [0, 3, 6, 9].map((offset) => row[start + offset] ?? "");
+ const firstMember = parseMember(labels[0], catalog);
+ if (!firstMember) continue;
+ const damageRow = matrix[rowIndex + 1] ?? [];
+ const damages = [0, 3, 6, 9].map((offset) =>
+ numeric(damageRow[start + offset] ?? ""),
+ );
+ if (damages.some((damage) => damage === null)) continue;
+ const title =
+ matrix[rowIndex - 1]?.[start]?.trim() ||
+ `${draft.source.characterName} Team`;
+ const rotationRow = matrix
+ .slice(rowIndex + 2, rowIndex + 5)
+ .find((candidate) => /TOTAL DPS/iu.test(candidate[start] ?? ""));
+ let rotation: number | null = rotationRow
+ ? Number(/\(([\d.]+)s\)/iu.exec(rotationRow[start] ?? "")?.[1])
+ : null;
+ const rotationIndex = rotationRow ? matrix.indexOf(rotationRow) : -1;
+ const combo =
+ rotationIndex >= 0
+ ? (matrix[rotationIndex + 1]?.[start]?.trim() ?? "")
+ : "";
+ if ((!Number.isFinite(rotation) || rotation <= 0) && rotationRow) {
+ const dps = rotationRow
+ .slice(start, start + 13)
+ .map(numeric)
+ .find((item): item is number => item !== null && item > 0);
+ if (dps)
+ rotation =
+ damages
+ .map((damage) => damage ?? 0)
+ .reduce((total, damage) => total + damage, 0) / dps;
+ }
+ if (!Number.isFinite(rotation) || rotation <= 0) rotation = null;
+ const explicitMembers = labels
+ .map((label) => parseMember(label, catalog))
+ .filter((member): member is NonNullable =>
+ Boolean(member),
+ );
+ const comboCharacters = charactersFromCombo(combo, catalog);
+ const members = [...explicitMembers];
+ for (const character of comboCharacters) {
+ if (!members.some((member) => member.characterKey === character.key)) {
+ members.push({
+ characterKey: character.key,
+ constellation: 0,
+ refinement: 1,
+ });
+ }
+ }
+ if (members.length !== 4) {
+ draft.skipped.push(
+ `${sheetName}: team ${title} has ${members.length} resolvable members`,
+ );
+ continue;
+ }
+ draft.teams.push({
+ title: title.slice(0, 160),
+ rotationSeconds: rotation,
+ combo: (combo || "-").slice(0, 2_000),
+ members: members.map((member, index) => ({
+ ...member,
+ weaponKey: null,
+ artifactKey: null,
+ secondArtifactKey: null,
+ damage: damages[index]!,
+ })),
+ });
+ start += 9;
+ }
+ }
+}
+
+function parseDetailedTeamExtra(
+ matrix: SheetMatrix,
+ sheetName: string,
+): GuideImportDraft["extras"][number] | null {
+ const rows: GuideImportDraft["extras"][number]["rows"] = [];
+ for (let rowIndex = 0; rowIndex < matrix.length - 6; rowIndex += 1) {
+ const header = matrix[rowIndex];
+ for (let start = 0; start < header.length; start += 1) {
+ if (!/ชื่อตัวละคร/u.test(header[start] ?? "")) continue;
+ const title = matrix[rowIndex - 1]?.[start]?.trim();
+ if (!title) continue;
+ const lines: string[] = [];
+ for (
+ let memberRow = rowIndex + 1;
+ memberRow <= rowIndex + 4;
+ memberRow += 1
+ ) {
+ const row = matrix[memberRow] ?? [];
+ const character = row[start + 1]?.trim();
+ const damage = row[start + 2]?.trim();
+ if (!character || numeric(damage) === null) continue;
+ const weapon = row[start + 3]?.trim();
+ const artifact = row[start + 4]?.trim();
+ lines.push(
+ `${character}: ${damage}${weapon ? ` | ${weapon}` : ""}${artifact ? ` | ${artifact}` : ""}`,
+ );
+ }
+ if (lines.length === 4)
+ rows.push({
+ heading: title.slice(0, 200),
+ body: lines.join("\n").slice(0, 2_000),
+ percent: null,
+ });
+ }
+ }
+ return rows.length
+ ? {
+ title: `${sheetName} — ข้อมูลทีม`.slice(0, 160),
+ rows: rows.slice(0, 50),
+ }
+ : null;
+}
+
+function parseExtra(
+ matrix: SheetMatrix,
+ title: string,
+): GuideImportDraft["extras"][number] | null {
+ const rows: GuideImportDraft["extras"][number]["rows"] = [];
+ for (const row of matrix) {
+ const values = row.map((cell) => cell.trim()).filter(Boolean);
+ if (!values.length) continue;
+ const metricIndex = values.findIndex((cell) => percent(cell) !== null);
+ const heading = values[0];
+ if (/^(?:MVs%|DPS%|ความหมาย)$/iu.test(heading)) continue;
+ const body = values
+ .filter((_, index) => index !== metricIndex)
+ .slice(1)
+ .join(" — ");
+ rows.push({
+ heading: heading.slice(0, 200),
+ body: body.slice(0, 2_000),
+ percent: metricIndex >= 0 ? percent(values[metricIndex]) : null,
+ });
+ if (rows.length === 50) break;
+ }
+ return rows.length ? { title: title.slice(0, 160), rows } : null;
+}
+
+export async function buildGuideImportDraft(
+ source: GuideImportSource,
+ catalog: ImportCatalog,
+): Promise {
+ const character = resolveCharacter(source.characterName, catalog.characters);
+ if (!character)
+ throw new Error(`${source.characterName}: character not in catalog`);
+ const draft = emptyDraft(source, character.key);
+ if (source.skipWrite) return draft;
+
+ if (source.layout === "full") {
+ const [weapons, artifacts, constellations] = await Promise.all([
+ fetchSheetCsv(source, "Weapons (อาวุธ)", "A1:BK80"),
+ fetchSheetCsv(source, "Artifacts & Stats", "A1:BK80"),
+ fetchSheetCsv(source, "Constellations (กลุ่มดาว)", "A1:BK40"),
+ ]);
+ parseFullInfo(weapons, draft, catalog);
+ const artifactDraft = emptyDraft(source, character.key);
+ parseFullInfo(artifacts, artifactDraft, catalog);
+ draft.artifacts = artifactDraft.artifacts;
+ draft.skipped.push(
+ ...artifactDraft.skipped.filter((item) => item.startsWith("artifact")),
+ );
+ const rows: GuideImportDraft["constellationGroups"][number]["rows"] = [];
+ for (const row of constellations) {
+ const value = percent(row[1] ?? "");
+ if (value === null || rows.length >= 6) continue;
+ const level = rows.length + 1;
+ const previous = rows.at(-1)?.cumulativePercent ?? 100;
+ rows.push({
+ level,
+ detail:
+ character.constellations.find((item) => item.level === level)
+ ?.description ||
+ row[5] ||
+ `C${level}`,
+ cumulativePercent: value,
+ isGreat: value - previous >= 10,
+ });
+ }
+ if (rows.length)
+ draft.constellationGroups.push({ note: "C0 = 100%", rows });
+ } else {
+ const info = await fetchSheetCsv(source, source.infoSheet, "A1:U100");
+ parseCompactInfo(info, draft, catalog);
+ }
+
+ for (const sheet of source.teamSheets) {
+ const matrix = await fetchSheetCsv(source, sheet, "A1:AN160");
+ parseTeams(matrix, draft, catalog, sheet);
+ const detailed = parseDetailedTeamExtra(matrix, sheet);
+ if (detailed) draft.extras.push(detailed);
+ }
+ for (const sheet of source.comboSheets) {
+ const matrix = await fetchSheetCsv(source, sheet, "A1:BU100");
+ const extra = parseExtra(matrix, sheet);
+ if (extra) draft.extras.push(extra);
+ }
+
+ draft.artifacts.sands = unique(draft.artifacts.sands).slice(0, 3);
+ draft.artifacts.goblet = unique(draft.artifacts.goblet).slice(0, 3);
+ draft.artifacts.circlet = unique(draft.artifacts.circlet).slice(0, 3);
+ draft.artifacts.substats = unique(draft.artifacts.substats);
+ draft.artifacts.targets = draft.artifacts.targets.filter(
+ (target, index, rows) =>
+ rows.findIndex((item) => item.stat === target.stat) === index,
+ );
+ draft.artifacts.talents = draft.artifacts.talents
+ .filter(
+ (talent, index, rows) =>
+ rows.findIndex((item) => item.type === talent.type) === index,
+ )
+ .slice(0, 3)
+ .map((talent, index) => ({ ...talent, priority: index + 1 }));
+ draft.weapons = draft.weapons.slice(0, 100);
+ draft.teams = draft.teams.slice(0, 30);
+ draft.extras = draft.extras.slice(0, 20);
+ return draft;
+}
diff --git a/package.json b/package.json
index 9f16bed..56a2173 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"db:migrate": "bun scripts/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-templates.ts",
+ "import:guides": "bun scripts/import-google-guides.ts",
"worker:discord": "bun backend/discord.ts",
"worker:outbox": "bun scripts/outbox-worker.ts"
},
diff --git a/scripts/import-google-guides.ts b/scripts/import-google-guides.ts
new file mode 100644
index 0000000..76bc12b
--- /dev/null
+++ b/scripts/import-google-guides.ts
@@ -0,0 +1,315 @@
+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 | 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 {
+ 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 {
+ 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["transaction"]>[0]>[0], name: string, key: string): Promise {
+ 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["transaction"]>[0]>[0],
+ draft: GuideImportDraft,
+ coverMediaId: string,
+ sortOrder: number,
+): Promise {
+ 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 {
+ const writable = drafts.filter((draft) => !draft.source.skipWrite);
+ const covers = new Map();
+ 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`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 {
+ 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();
+}