feat : update excel
CI / Verify (push) Successful in 1m11s
CI / Build immutable images and deploy (push) Successful in 2m0s

This commit is contained in:
2026-09-05 19:30:50 +07:00 Unverified
parent 2d651fbe56
commit 66238795bf
6 changed files with 529 additions and 67 deletions
+18
View File
@@ -12,6 +12,7 @@ import {
moveExtraSheetColumn,
moveExtraSheetRow,
normalizeExtraSheetRichText,
styleExtraSheetCells,
updateExtraSheetCell,
updateExtraSheetCells,
type ExtraSheet,
@@ -124,6 +125,23 @@ describe("Extra sheet", () => {
.rows[0].backgrounds).toEqual({});
});
it("styles selected cells without replacing their individual text", () => {
const sheet = createExtraSheet(idFactory());
const firstColumn = sheet.columns[0].id;
const secondColumn = sheet.columns[1].id;
sheet.rows[0].cells[firstColumn] = "<p>Alpha</p>";
sheet.rows[1].cells[secondColumn] = "<p><em>Beta</em></p>";
const styled = styleExtraSheetCells(sheet, [
{ row: 0, column: 0 },
{ row: 1, column: 1 },
], { bold: true });
expect(styled.rows[0].cells[firstColumn]).toBe("<p><strong>Alpha</strong></p>");
expect(styled.rows[1].cells[secondColumn]).toBe("<p><em><strong>Beta</strong></em></p>");
expect(styled.rows[0].cells[secondColumn]).toBe("");
});
it("renders legacy sheets that do not have background metadata yet", () => {
const sheet = createExtraSheet(idFactory());
const legacy = {
+28 -1
View File
@@ -1,6 +1,10 @@
import * as z from "zod";
import { normalizeExtraRichText } from "@/lib/rich-text";
import {
applyRichTextStylePatch,
normalizeExtraRichText,
type RichTextStylePatch,
} from "@/lib/rich-text";
const extraSheetId = z.string().min(1).max(128);
@@ -213,6 +217,29 @@ export function updateExtraSheetCells(
};
}
export function styleExtraSheetCells(
sheet: ExtraSheet,
points: Iterable<SheetPoint>,
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,
+34
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
applyRichTextStylePatch,
normalizeExtraRichText,
richTextContentSignature,
sanitizeRichTextHtml,
sanitizeRichTextUrl,
} from "@/lib/rich-text";
@@ -46,3 +48,35 @@ describe("legacy Extra rich text conversion", () => {
])).toBe("<p><em>Ready</em></p>");
});
});
describe("bulk rich-text styles", () => {
it("applies a style to every text node without replacing structure or links", () => {
expect(applyRichTextStylePatch(
'<p>First <a href="https://example.com">link</a></p><ul><li>Second</li></ul>',
{ bold: true },
)).toBe(
'<p><strong>First </strong><a href="https://example.com" target="_blank" rel="noopener noreferrer"><strong>link</strong></a></p><ul><li><strong>Second</strong></li></ul>',
);
});
it("removes only requested styles and applies normalized colors", () => {
expect(applyRichTextStylePatch(
'<p><strong>Bold <em>and italic</em></strong></p>',
{ bold: false },
)).toBe("<p>Bold <em>and italic</em></p>");
expect(applyRichTextStylePatch("<p>Red</p>", { color: "#00ff00" }))
.toBe('<p><span style="color: #00FF00">Red</span></p>');
expect(applyRichTextStylePatch(
'<p><span style="color: #00FF00">Green</span></p>',
{ color: null },
)).toBe("<p>Green</p>");
});
it("compares content independently from visual formatting", () => {
expect(richTextContentSignature(
'<p><strong>Same</strong> <span style="color: #FF0000">text</span></p>',
)).toBe("<p>Same text</p>");
expect(richTextContentSignature("<p>Changed text</p>"))
.not.toBe(richTextContentSignature("<p>Original text</p>"));
});
});
+63
View File
@@ -24,6 +24,14 @@ const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
export interface RichTextStylePatch {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string | null;
}
function normalizeRichTextColor(value: string): string | null {
const color = value.trim();
if (HEX_COLOR.test(color)) return color.toUpperCase();
@@ -120,3 +128,58 @@ export function normalizeExtraRichText(
return content ? `<p>${content}</p>` : "";
}
const RICH_TEXT_MARK_TAGS = {
bold: "strong",
italic: "em",
underline: "u",
strike: "s",
} as const;
function removeRichTextTag(value: string, tag: string): string {
return value.replace(new RegExp(`</?${tag}(?:\\s[^>]*)?>`, "gi"), "");
}
function wrapRichTextNodes(value: string, tag: string, attributes = ""): string {
return value
.split(/(<[^>]+>)/g)
.map((part) => part && !part.startsWith("<")
? `<${tag}${attributes}>${part}</${tag}>`
: part)
.join("");
}
/** Returns rich-text structure with visual marks removed for content comparisons. */
export function richTextContentSignature(value: string): string {
let result = normalizeExtraRichText(value);
Object.values(RICH_TEXT_MARK_TAGS).forEach((tag) => {
result = removeRichTextTag(result, tag);
});
return removeRichTextTag(result, "span");
}
/** Applies uniform visual formatting without replacing a cell's own content. */
export function applyRichTextStylePatch(
value: string,
patch: RichTextStylePatch,
): string {
let result = normalizeExtraRichText(value);
(Object.entries(RICH_TEXT_MARK_TAGS) as Array<
[keyof Omit<RichTextStylePatch, "color">, string]
>).forEach(([format, tag]) => {
if (patch[format] === undefined) return;
result = removeRichTextTag(result, tag);
if (patch[format]) result = wrapRichTextNodes(result, tag);
});
if (patch.color !== undefined) {
result = removeRichTextTag(result, "span");
const color = patch.color ? normalizeRichTextColor(patch.color) : null;
if (color) {
result = wrapRichTextNodes(result, "span", ` style="color: ${color}"`);
}
}
return sanitizeRichTextHtml(result);
}