import * as z from "zod"; import { applyRichTextStylePatch, clearRichTextFormatting, extraRichTextToPlainText, normalizeExtraRichText, type RichTextStylePatch, } from "@/lib/rich-text"; const extraSheetId = z.string().min(1).max(128); export const DEFAULT_EXTRA_SHEET_COLUMN_WIDTH = 140; export const MIN_EXTRA_SHEET_COLUMN_WIDTH = 96; export const MAX_EXTRA_SHEET_COLUMN_WIDTH = 480; export const extraSheetAlignmentSchema = z.enum(["left", "center", "right"]); export type ExtraSheetAlignment = z.infer; export const extraSheetSchema = z.strictObject({ columns: z.array(z.strictObject({ id: extraSheetId, label: z.string().trim().min(1).max(120), width: z.number().int().min(MIN_EXTRA_SHEET_COLUMN_WIDTH).max(MAX_EXTRA_SHEET_COLUMN_WIDTH) .default(DEFAULT_EXTRA_SHEET_COLUMN_WIDTH), })).min(1), rows: z.array(z.strictObject({ id: extraSheetId, cells: z.record(z.string(), z.string().max(10_000)), backgrounds: z.record(z.string(), z.string().regex(/^#[0-9A-Fa-f]{6}$/)).default({}), alignments: z.record(z.string(), extraSheetAlignmentSchema).default({}), })).min(1), }).superRefine((sheet, context) => { const columnIds = new Set(sheet.columns.map((column) => column.id)); if (columnIds.size !== sheet.columns.length) { context.addIssue({ code: "custom", path: ["columns"], message: "รหัสคอลัมน์ต้องไม่ซ้ำกัน" }); } const columnLabels = new Set(sheet.columns.map((column) => column.label.toLocaleLowerCase())); if (columnLabels.size !== sheet.columns.length) { context.addIssue({ code: "custom", path: ["columns"], message: "ชื่อคอลัมน์ต้องไม่ซ้ำกัน" }); } const rowIds = new Set(sheet.rows.map((row) => row.id)); if (rowIds.size !== sheet.rows.length) { context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" }); } sheet.rows.forEach((row, rowIndex) => { [ ...Object.keys(row.cells), ...Object.keys(row.backgrounds ?? {}), ...Object.keys(row.alignments ?? {}), ].forEach((columnId) => { if (!columnIds.has(columnId)) { context.addIssue({ code: "custom", path: ["rows", rowIndex, "cells", columnId], message: "ไม่พบคอลัมน์ของเซลล์นี้", }); } }); }); }); export type ExtraSheet = z.output; export type ExtraSheetCell = { value: string; backgroundColor?: string; alignment?: ExtraSheetAlignment; }; export type ExtraSheetMatrix = Array>; type SheetPoint = { row: number; column: number }; export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string { const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase())); let index = columns.length + 1; while (labels.has(`คอลัมน์ ${index}`.toLocaleLowerCase())) index += 1; return `คอลัมน์ ${index}`; } export function createExtraSheet(createId: (prefix: string) => string): ExtraSheet { const columns = [1, 2].map((index) => ({ id: createId("extra-sheet-column"), label: `คอลัมน์ ${index}`, width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, })); return { columns, rows: [1, 2].map(() => ({ id: createId("extra-sheet-row"), cells: Object.fromEntries(columns.map((column) => [column.id, ""])), backgrounds: {}, alignments: {}, })), }; } export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix { return sheet.rows.map((row) => sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "", backgroundColor: row.backgrounds?.[column.id], alignment: row.alignments?.[column.id], })), ); } export function matrixToExtraSheet( sheet: ExtraSheet, matrix: ExtraSheetMatrix, createId: (prefix: string) => string, ): ExtraSheet { const columnCount = Math.max( sheet.columns.length, ...matrix.map((row) => row.length), ); const columns = [...sheet.columns]; while (columns.length < columnCount) { columns.push({ id: createId("extra-sheet-column"), label: nextExtraSheetColumnLabel(columns), width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, }); } const rowCount = Math.max(sheet.rows.length, matrix.length); const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({ id: sheet.rows[rowIndex]?.id ?? createId("extra-sheet-row"), cells: Object.fromEntries(columns.map((column, columnIndex) => [ column.id, String(matrix[rowIndex]?.[columnIndex]?.value ?? ""), ])), backgrounds: Object.fromEntries(columns.flatMap((column, columnIndex) => { const backgroundColor = matrix[rowIndex]?.[columnIndex]?.backgroundColor ?? sheet.rows[rowIndex]?.backgrounds?.[column.id]; return backgroundColor ? [[column.id, backgroundColor]] : []; })), alignments: Object.fromEntries(columns.flatMap((column, columnIndex) => { const alignment = matrix[rowIndex]?.[columnIndex]?.alignment ?? sheet.rows[rowIndex]?.alignments?.[column.id]; return alignment ? [[column.id, alignment]] : []; })), })); return { columns, rows }; } export function addExtraSheetColumn( sheet: ExtraSheet, label: string, id: string, ): ExtraSheet { return insertExtraSheetColumn(sheet, sheet.columns.length, label, id); } export function insertExtraSheetColumn( sheet: ExtraSheet, index: number, label: string, id: string, ): ExtraSheet { const position = Math.max(0, Math.min(index, sheet.columns.length)); const columns = [...sheet.columns]; columns.splice(position, 0, { id, label, width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, }); return { columns, rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })), }; } export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet { return insertExtraSheetRow(sheet, sheet.rows.length, id); } export function insertExtraSheetRow( sheet: ExtraSheet, index: number, id: string, ): ExtraSheet { const position = Math.max(0, Math.min(index, sheet.rows.length)); const rows = [...sheet.rows]; rows.splice(position, 0, { id, cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])), backgrounds: {}, alignments: {}, }); return { ...sheet, rows, }; } export function resizeExtraSheetColumn( sheet: ExtraSheet, index: number, width: number, ): ExtraSheet { if (!sheet.columns[index]) return sheet; const normalized = Math.round(Math.max( MIN_EXTRA_SHEET_COLUMN_WIDTH, Math.min(MAX_EXTRA_SHEET_COLUMN_WIDTH, width), )); return { ...sheet, columns: sheet.columns.map((column, columnIndex) => columnIndex === index ? { ...column, width: normalized } : column), }; } export function moveExtraSheetColumn( sheet: ExtraSheet, from: number, to: number, ): ExtraSheet { if (to < 0 || to >= sheet.columns.length || from === to) return sheet; const columns = [...sheet.columns]; const [column] = columns.splice(from, 1); columns.splice(to, 0, column); return { ...sheet, columns }; } export function moveExtraSheetRow( sheet: ExtraSheet, from: number, to: number, ): ExtraSheet { if (to < 0 || to >= sheet.rows.length || from === to) return sheet; const rows = [...sheet.rows]; const [row] = rows.splice(from, 1); rows.splice(to, 0, row); return { ...sheet, rows }; } export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraSheet { if (sheet.columns.length === 1) return sheet; const columnId = sheet.columns[index]?.id; if (!columnId) return sheet; return { columns: sheet.columns.filter((_, columnIndex) => columnIndex !== index), rows: sheet.rows.map((row) => ({ ...row, cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)), backgrounds: Object.fromEntries(Object.entries(row.backgrounds ?? {}).filter(([key]) => key !== columnId)), alignments: Object.fromEntries(Object.entries(row.alignments ?? {}).filter(([key]) => key !== columnId)), })), }; } export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraSheet { 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 updateExtraSheetCells( sheet: ExtraSheet, points: Iterable, update: { value?: string; backgroundColor?: string | null; alignment?: ExtraSheetAlignment | null; }, ): ExtraSheet { const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); return { ...sheet, rows: sheet.rows.map((row, rowIndex) => { const cells = { ...row.cells }; const backgrounds = { ...(row.backgrounds ?? {}) }; const alignments = { ...(row.alignments ?? {}) }; sheet.columns.forEach((column, columnIndex) => { if (!selected.has(`${rowIndex}:${columnIndex}`)) return; if (update.value !== undefined) cells[column.id] = update.value; if (update.backgroundColor === null) delete backgrounds[column.id]; else if (update.backgroundColor !== undefined) backgrounds[column.id] = update.backgroundColor; if (update.alignment === null) delete alignments[column.id]; else if (update.alignment !== undefined) alignments[column.id] = update.alignment; }); return { ...row, cells, backgrounds, alignments }; }), }; } export function clearExtraSheetCellFormatting( sheet: ExtraSheet, points: Iterable, ): ExtraSheet { const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); return { ...sheet, rows: sheet.rows.map((row, rowIndex) => { const cells = { ...row.cells }; const backgrounds = { ...(row.backgrounds ?? {}) }; const alignments = { ...(row.alignments ?? {}) }; sheet.columns.forEach((column, columnIndex) => { if (!selected.has(`${rowIndex}:${columnIndex}`)) return; cells[column.id] = clearRichTextFormatting(cells[column.id] ?? ""); delete backgrounds[column.id]; delete alignments[column.id]; }); return { ...row, cells, backgrounds, alignments }; }), }; } function quoteTsvValue(value: string): string { return /[\t\r\n"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; } export function extraSheetRangeToTsv( sheet: ExtraSheet, start: SheetPoint, end: SheetPoint, ): string { const top = Math.min(start.row, end.row); const bottom = Math.max(start.row, end.row); const left = Math.min(start.column, end.column); const right = Math.max(start.column, end.column); return sheet.rows.slice(top, bottom + 1).map((row) => sheet.columns.slice(left, right + 1).map((column) => quoteTsvValue(extraRichTextToPlainText(row.cells[column.id] ?? "")), ).join("\t"), ).join("\n"); } export function parseExtraSheetTsv(value: string): string[][] { const rows: string[][] = [[]]; let field = ""; let quoted = false; for (let index = 0; index < value.length; index += 1) { const character = value[index]; if (character === '"') { if (quoted && value[index + 1] === '"') { field += '"'; index += 1; } else quoted = !quoted; } else if (character === "\t" && !quoted) { rows.at(-1)!.push(field); field = ""; } else if ((character === "\n" || character === "\r") && !quoted) { if (character === "\r" && value[index + 1] === "\n") index += 1; rows.at(-1)!.push(field); rows.push([]); field = ""; } else field += character; } rows.at(-1)!.push(field); return rows.length > 1 && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === "" ? rows.slice(0, -1) : rows; } export function pasteExtraSheetText( sheet: ExtraSheet, start: SheetPoint, text: string, createId: (prefix: string) => string, ): ExtraSheet { const pasted = parseExtraSheetTsv(text); if (!pasted.length) return sheet; let next = sheet; const requiredRows = start.row + pasted.length; const requiredColumns = start.column + Math.max(...pasted.map((row) => row.length)); while (next.rows.length < requiredRows) { next = addExtraSheetRow(next, createId("extra-sheet-row")); } while (next.columns.length < requiredColumns) { next = addExtraSheetColumn( next, nextExtraSheetColumnLabel(next.columns), createId("extra-sheet-column"), ); } return { ...next, rows: next.rows.map((row, rowIndex) => { const pastedRow = pasted[rowIndex - start.row]; if (!pastedRow) return row; const cells = { ...row.cells }; pastedRow.forEach((cell, offset) => { const column = next.columns[start.column + offset]; if (column) cells[column.id] = normalizeExtraRichText(cell); }); return { ...row, cells }; }), }; } export function styleExtraSheetCells( sheet: ExtraSheet, points: Iterable, patch: RichTextStylePatch, ): ExtraSheet { const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); return { ...sheet, rows: sheet.rows.map((row, rowIndex) => ({ ...row, cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => { const columnIndex = sheet.columns.findIndex((column) => column.id === columnId); return [ columnId, selected.has(`${rowIndex}:${columnIndex}`) ? applyRichTextStylePatch(value, patch) : value, ]; })), })), }; } 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), ])), })), }; }