feat : update
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
matrixToExtraSheet,
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
normalizeExtraSheetRichText,
|
||||
updateExtraSheetCell,
|
||||
} from "@/lib/guides/extra-sheet";
|
||||
|
||||
function idFactory() {
|
||||
@@ -87,4 +89,17 @@ describe("Extra sheet", () => {
|
||||
expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
|
||||
}
|
||||
});
|
||||
|
||||
it("updates and sanitizes rich-text cells while preserving legacy text", () => {
|
||||
const initial = createExtraSheet(idFactory());
|
||||
const updated = updateExtraSheetCell(initial, 0, 0, "legacy\ntext");
|
||||
const columnId = updated.columns[0].id;
|
||||
expect(normalizeExtraSheetRichText(updated).rows[0].cells[columnId]).toBe(
|
||||
"<p>legacy<br>text</p>",
|
||||
);
|
||||
const unsafe = updateExtraSheetCell(updated, 0, 0, "<p><strong>safe</strong><script>bad()</script></p>");
|
||||
expect(normalizeExtraSheetRichText(unsafe).rows[0].cells[columnId]).toBe(
|
||||
"<p><strong>safe</strong></p>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
const extraSheetId = z.string().min(1).max(128);
|
||||
|
||||
export const extraSheetSchema = z.strictObject({
|
||||
@@ -158,3 +160,33 @@ export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraShee
|
||||
if (sheet.rows.length === 1) return sheet;
|
||||
return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
|
||||
}
|
||||
|
||||
export function updateExtraSheetCell(
|
||||
sheet: ExtraSheet,
|
||||
rowIndex: number,
|
||||
columnIndex: number,
|
||||
value: string,
|
||||
): ExtraSheet {
|
||||
const row = sheet.rows[rowIndex];
|
||||
const column = sheet.columns[columnIndex];
|
||||
if (!row || !column) return sheet;
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((candidate, index) => index === rowIndex
|
||||
? { ...candidate, cells: { ...candidate.cells, [column.id]: value } }
|
||||
: candidate),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet {
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row) => ({
|
||||
...row,
|
||||
cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => [
|
||||
columnId,
|
||||
normalizeExtraRichText(value),
|
||||
])),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function saveWeapons(guideId: string, input: unknown) {
|
||||
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
const [created] = await tx.insert(weaponConditions).values({ guideId, note: group.note, sortOrder }).returning();
|
||||
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
||||
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
+19
-12
@@ -16,6 +16,7 @@ import {
|
||||
extraSectionRows,
|
||||
guides,
|
||||
guideSections,
|
||||
glossaryAliases,
|
||||
media,
|
||||
teamCompositions,
|
||||
teamMembers,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
weaponConditions,
|
||||
} from "@/db/schema";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
import { aliasesByItem } from "@/lib/glossary";
|
||||
|
||||
const catalogVersionSchema = z.object({ version: z.string() });
|
||||
|
||||
@@ -46,21 +48,23 @@ function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
|
||||
|
||||
export async function getActiveCatalog() {
|
||||
const db = getDb();
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
const [characters, weapons, artifacts, aliases] = await Promise.all([
|
||||
db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
|
||||
db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
|
||||
db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
|
||||
db.select().from(glossaryAliases),
|
||||
]);
|
||||
if (!characters.length) return null;
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
const storage = await getMediaStorage();
|
||||
const { version } = catalogVersionSchema.parse(
|
||||
await storage.file("catalog/current.json").json(),
|
||||
);
|
||||
return {
|
||||
version,
|
||||
characters: characters.map(catalogCharacterWithUrls),
|
||||
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item), aliases: groupedAliases.character.get(item.key) ?? [] })),
|
||||
weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,13 +79,14 @@ export async function listGuideCards(includeTrash = false) {
|
||||
}
|
||||
|
||||
export async function listPublicGuideCards() {
|
||||
const rows = await getDb()
|
||||
const [rows, aliases] = await Promise.all([getDb()
|
||||
.select({ guide: guides, cover: media })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
|
||||
.orderBy(asc(guides.sortOrder), asc(guides.name));
|
||||
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||
.orderBy(asc(guides.sortOrder), asc(guides.name)), getDb().select().from(glossaryAliases)]);
|
||||
const grouped = aliasesByItem(aliases);
|
||||
return rows.map(({ guide, cover }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||
}
|
||||
|
||||
export async function getGuideByCharacterKey(characterKey: string) {
|
||||
@@ -109,14 +114,16 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases] = await Promise.all([
|
||||
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
|
||||
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
|
||||
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
|
||||
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
|
||||
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
|
||||
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
|
||||
getDb().select().from(glossaryAliases),
|
||||
]);
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
|
||||
const elementIconUrls = Object.fromEntries(
|
||||
(await getDb()
|
||||
@@ -161,11 +168,11 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
character: catalogCharacterWithUrls(row.character),
|
||||
character: { ...catalogCharacterWithUrls(row.character), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
||||
elementIconUrls,
|
||||
sections,
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
|
||||
artifactProfile: profile[0] ?? null,
|
||||
constellationGroups: constellationGroups.map((group) => ({
|
||||
@@ -180,7 +187,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
};
|
||||
}),
|
||||
})),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
|
||||
...extraRow,
|
||||
catalogItems: extraRow.catalogItems.map((reference) => {
|
||||
|
||||
@@ -35,6 +35,12 @@ describe("structured guide validation", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }] }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("supports an all-refinements weapon range", () => {
|
||||
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: "1-5", overallPercent: 100, personalPercent: null, note: null }] }] });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.groups[0].rows[0].refinement).toBe("1-5");
|
||||
});
|
||||
|
||||
it("preserves negative weapon percentages", () => {
|
||||
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] }] });
|
||||
expect(result.success).toBe(true);
|
||||
@@ -229,7 +235,7 @@ describe("structured guide validation", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1");
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("<p>=1+1</p>");
|
||||
}
|
||||
|
||||
const invalidSheet = structuredClone(sheet);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as z from "zod";
|
||||
|
||||
import { targetStatOptions } from "@/lib/guides/stats";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { extraSheetSchema } from "@/lib/guides/extra-sheet";
|
||||
import { extraSheetSchema, normalizeExtraSheetRichText } from "@/lib/guides/extra-sheet";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||
@@ -10,6 +10,11 @@ export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||
|
||||
const percent = z.coerce.number().finite().min(-9999).max(9999);
|
||||
const catalogKey = z.string().trim().min(1).max(64);
|
||||
export const weaponRefinementSchema = z.union([
|
||||
z.number().int().min(1).max(5).transform(String),
|
||||
z.enum(["1", "2", "3", "4", "5", "1-5"]),
|
||||
]);
|
||||
export type WeaponRefinement = z.output<typeof weaponRefinementSchema>;
|
||||
|
||||
export const createGuideSchema = z.strictObject({
|
||||
characterKey: catalogKey,
|
||||
@@ -40,7 +45,7 @@ export const weaponSectionSchema = z.strictObject({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: z.array(z.strictObject({
|
||||
weaponKey: catalogKey,
|
||||
refinement: z.number().int().min(1).max(5),
|
||||
refinement: weaponRefinementSchema,
|
||||
overallPercent: percent.nullable(),
|
||||
personalPercent: percent.nullable(),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
@@ -159,6 +164,7 @@ const extraRowSchema = z.strictObject({
|
||||
}).transform((row) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
sheet: row.sheet ? normalizeExtraSheetRichText(row.sheet) : null,
|
||||
highlights: [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { matchesTeamSearch } from "./team-search";
|
||||
import { matchesTeamSearch, sortTeamsByDps } from "./team-search";
|
||||
|
||||
const team = {
|
||||
team: ["Vaporize Team"],
|
||||
@@ -28,4 +28,14 @@ describe("team search", () => {
|
||||
it("matches every team when the query is empty", () => {
|
||||
expect(matchesTeamSearch(team, " ", "weapon")).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts every team by DPS and preserves order for ties", () => {
|
||||
const teams = [
|
||||
{ name: "middle", rotationSeconds: 10, members: [{ damage: 100 }] },
|
||||
{ name: "best", rotationSeconds: 5, members: [{ damage: 100 }] },
|
||||
{ name: "tie", rotationSeconds: 20, members: [{ damage: 200 }] },
|
||||
];
|
||||
expect(sortTeamsByDps(teams, "best").map((item) => item.name)).toEqual(["best", "middle", "tie"]);
|
||||
expect(sortTeamsByDps(teams, "worst").map((item) => item.name)).toEqual(["middle", "tie", "best"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,32 @@ export interface TeamSearchValues {
|
||||
artifact: string[];
|
||||
}
|
||||
|
||||
export interface DpsTeam {
|
||||
rotationSeconds: string | number;
|
||||
members: Array<{ damage: string | number }>;
|
||||
}
|
||||
|
||||
export function teamDps(team: DpsTeam): number {
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
if (!(seconds > 0)) return 0;
|
||||
return team.members.reduce((sum, member) => sum + Number(member.damage), 0) / seconds;
|
||||
}
|
||||
|
||||
export function sortTeamsByDps<Team extends DpsTeam>(
|
||||
teams: readonly Team[],
|
||||
order: "best" | "worst",
|
||||
): Team[] {
|
||||
return teams
|
||||
.map((team, index) => ({ team, index }))
|
||||
.sort((left, right) => {
|
||||
const difference = order === "best"
|
||||
? teamDps(right.team) - teamDps(left.team)
|
||||
: teamDps(left.team) - teamDps(right.team);
|
||||
return difference || left.index - right.index;
|
||||
})
|
||||
.map(({ team }) => team);
|
||||
}
|
||||
|
||||
export function matchesTeamSearch(
|
||||
values: TeamSearchValues,
|
||||
query: string,
|
||||
|
||||
Reference in New Issue
Block a user