From de94486af7e89cbef87034db4f5557f69fa7ae99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:27:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=8F=84=EA=B5=AC(?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=82=98=EB=88=84=EA=B8=B0=C2=B7?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=C2=B7=EC=97=B0=EC=86=8D?= =?UTF-8?q?=20=EC=B1=84=EC=9A=B0=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 텍스트 나누기(구분 기호 탭·세미콜론·쉼표·공백·사용자 지정 또는 고정 폭 · 미리보기) · 중복된 항목 제거(비교할 열 고르기 · 머리글 포함 여부 · 지운 수를 토스트로 알림) · 채우기 연속 데이터(단계 값 · 끝 값 · 행/열 방향)를 새 파일로 짬. 셋 다 「칸」 명령 하나로만 문서를 바꿈. 순수 로직(나누기 · 중복 찾기 · 등차수열 짓기)은 DOM 없이도 시험 가능해 resources/tester/spreadsheet/test_stage2_data_tools.ts 로 다 검증함. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- .../spreadsheet/spreadsheet_data_tools.ts | 422 ++++++++++++++++++ .../spreadsheet/test_stage2_data_tools.ts | 113 +++++ 2 files changed, 535 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_data_tools.ts create mode 100644 resources/tester/spreadsheet/test_stage2_data_tools.ts diff --git a/A00_Common/spreadsheet/spreadsheet_data_tools.ts b/A00_Common/spreadsheet/spreadsheet_data_tools.ts new file mode 100644 index 00000000..5d935a5a --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_data_tools.ts @@ -0,0 +1,422 @@ +/* ============================================================================= + * spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1) + * 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거 + * (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열). + * 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀. + * + * 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` · + * `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름 + * (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함). + * + * 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를 + * 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { fracToString } from "@ui/sheet/ui_template_sheet_frac"; +import { colName, toA1 } from "./spreadsheet_address"; +import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + textToColumns: "텍스트 나누기", + byDelimiter: "구분 기호로", + byFixedWidth: "고정 폭으로", + delimiters: "구분 기호", + tab: "탭", + semicolon: "세미콜론", + comma: "쉼표", + space: "공백", + custom: "사용자 지정", + breaks: "자를 자리(문자 수, 쉼표로 구분)", + preview: "미리보기", + run: "실행", + cancel: "취소", + removeDuplicates: "중복된 항목 제거", + hasHeader: "머리글 포함", + keyColumns: "비교할 열", + removed: (n: number) => `중복 ${n}개 지움`, + fillSeries: "연속 데이터 채우기", + direction: "방향", + down: "아래로(열)", + right: "오른쪽으로(행)", + step: "단계 값", + stop: "끝 값(선택)", +}; + +// ═════════════════════════════════════════════════════════════════════════════ +// 공용 — 값 ↔ 글, 작은 대화상자 뼈대 +// ═════════════════════════════════════════════════════════════════════════════ + +function scalarText(v: Scalar): string { + if (v == null) return ""; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + if (typeof v === "string") return v; + if (typeof v === "object" && "error" in v) return v.error; + return fracToString(v); +} + +function cellInputFrom(text: string): CellInput { + const n = Number(text); + return text !== "" && !Number.isNaN(n) ? n : text; +} + +function row(label: string, ...controls: HTMLElement[]): HTMLElement { + const r = el("div", { + children: [el("span", { text: label }), ...controls], + }); + r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;"; + return r; +} + +/** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */ +function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + panel.style.minWidth = "360px"; + const close = (): void => overlay.remove(); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + panel.append(el("h3", { text: title }), ...body); + panel.append( + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ + label: runLabel, + variant: "filled", + onClick: () => { + onRun(); + close(); + }, + }), + ], + }), + ); + overlay.append(panel); + document.body.append(overlay); +} + +function readRange(ctx: SpreadsheetContext): CellRange | null { + return ctx.selection.범위[0] ?? null; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 1. 텍스트 나누기 +// ═════════════════════════════════════════════════════════════════════════════ + +export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자"; + +const DELIM_CHARS: Record, string> = { + 탭: "\t", + 세미콜론: ";", + 쉼표: ",", + 공백: " ", +}; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */ +export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] { + const chars = kinds + .map((k) => (k === "사용자" ? custom : DELIM_CHARS[k])) + .filter((c): c is string => !!c); + if (chars.length === 0) return [line]; + return line.split(new RegExp(chars.map(escapeRegExp).join("|"))); +} + +/** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */ +export function splitFixedWidth(line: string, breaks: number[]): string[] { + const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length]; + const sorted = [...new Set(points)].sort((a, b) => a - b); + const out: string[] = []; + for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1])); + return out; +} + +/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */ +export function applyTextToColumns( + ctx: SpreadsheetContext, + anchor: CellAddress, + rows: string[][], +): void { + const cells: Record = {}; + rows.forEach((cols, ri) => { + cols.forEach((text, ci) => { + cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) }; + }); + }); + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function openTextToColumnsDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const lines: string[] = []; + for (let r = range.r0; r <= range.r1; r++) { + lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0))); + } + + let mode: "구분" | "고정" = "구분"; + let kinds: DelimiterKind[] = ["쉼표"]; + let custom = ""; + let breaksText = ""; + + const modeSelect = el("select", { + children: [ + el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }), + el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }), + ], + }) as HTMLSelectElement; + + const delimBox = el("div"); + const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => { + const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + check.checked = kinds.includes(k); + check.addEventListener("change", () => { + kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k); + refreshPreview(); + }); + return row( + k === "탭" + ? TEXT.tab + : k === "세미콜론" + ? TEXT.semicolon + : k === "쉼표" + ? TEXT.comma + : k === "공백" + ? TEXT.space + : TEXT.custom, + check, + ); + }); + const customInput = createInputField({ + label: TEXT.custom, + onInput: (v) => { + custom = v; + refreshPreview(); + }, + }); + delimBox.append(...kindChecks, customInput.root); + + const breaksField = createInputField({ + label: TEXT.breaks, + placeholder: "5,10,20", + onInput: (v) => { + breaksText = v; + refreshPreview(); + }, + }); + breaksField.root.hidden = true; + + const previewBox = el("pre"); + previewBox.style.cssText = + "max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;"; + + function splitAll(): string[][] { + if (mode === "고정") { + const breaks = breaksText + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + return lines.map((l) => splitFixedWidth(l, breaks)); + } + return lines.map((l) => splitDelimited(l, kinds, custom)); + } + + function refreshPreview(): void { + previewBox.textContent = splitAll() + .slice(0, 8) + .map((cols) => cols.join(" | ")) + .join("\n"); + } + + modeSelect.addEventListener("change", () => { + mode = modeSelect.value as "구분" | "고정"; + delimBox.hidden = mode !== "구분"; + breaksField.root.hidden = mode !== "고정"; + refreshPreview(); + }); + + refreshPreview(); + + modal( + TEXT.textToColumns, + [ + row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect), + delimBox, + breaksField.root, + el("div", { text: TEXT.preview }), + previewBox, + ], + () => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()), + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 2. 중복된 항목 제거 +// ═════════════════════════════════════════════════════════════════════════════ + +/** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */ +export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] { + const seen = new Set(); + const dup: number[] = []; + rows.forEach((r, i) => { + const key = keyCols.map((c) => r[c] ?? "").join("\u0001"); + if (seen.has(key)) dup.push(i); + else seen.add(key); + }); + return dup; +} + +/** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */ +export function removeDuplicateRows( + ctx: SpreadsheetContext, + range: CellRange, + keyCols: number[], + skipHeader: boolean, +): number { + const width = range.c1 - range.c0 + 1; + const bodyR0 = range.r0 + (skipHeader ? 1 : 0); + const rows: string[][] = []; + for (let r = bodyR0; r <= range.r1; r++) { + const line: string[] = []; + for (let c = 0; c < width; c++) + line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c))); + rows.push(line); + } + const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols)); + const kept = rows.filter((_, i) => !dupIdx.has(i)); + + const cells: Record = {}; + for (let i = 0; i < rows.length; i++) { + const r = bodyR0 + i; + for (let c = 0; c < width; c++) { + const text = i < kept.length ? kept[i][c] : ""; + cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) }; + } + } + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + return dupIdx.size; +} + +export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const width = range.c1 - range.c0 + 1; + + let hasHeader = width > 1 || range.r1 > range.r0; + const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + headerCheck.checked = hasHeader; + + const keyCols = new Set(Array.from({ length: width }, (_, i) => i)); + const colChecks = Array.from({ length: width }, (_, i) => { + const c = range.c0 + i; + const label = hasHeader + ? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c) + : colName(c); + const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + check.checked = true; + check.addEventListener("change", () => { + if (check.checked) keyCols.add(i); + else keyCols.delete(i); + }); + return row(label, check); + }); + + headerCheck.addEventListener("change", () => { + hasHeader = headerCheck.checked; + }); + + modal( + TEXT.removeDuplicates, + [row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks], + () => { + const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader); + showToast(TEXT.removed(removed), "success"); + }, + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 3. 채우기 연속 데이터 +// ═════════════════════════════════════════════════════════════════════════════ + +export type SeriesDirection = "행" | "열"; + +/** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */ +export function buildLinearSeries( + start: number, + step: number, + stop: number | null, + count: number, +): number[] { + const out: number[] = []; + for (let i = 0; i < count; i++) { + const v = start + step * i; + if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break; + out.push(v); + } + return out; +} + +export function applyFillSeries( + ctx: SpreadsheetContext, + anchor: CellAddress, + direction: SeriesDirection, + values: number[], +): void { + const cells: Record = {}; + values.forEach((v, i) => { + const r = direction === "열" ? anchor.r + i : anchor.r; + const c = direction === "행" ? anchor.c + i : anchor.c; + cells[toA1(r, c)] = { 값: v }; + }); + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function openFillSeriesDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const at = ctx.selection.활성; + const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c)); + const start = Number(startText) || 0; + + let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행"; + let step = 1; + let stop: number | null = null; + + const dirSelect = el("select", { + children: [ + el("option", { attrs: { value: "열" }, text: TEXT.down }), + el("option", { attrs: { value: "행" }, text: TEXT.right }), + ], + }) as HTMLSelectElement; + dirSelect.value = direction; + dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection)); + + const stepField = createInputField({ + label: TEXT.step, + type: "number", + value: "1", + onInput: (v) => (step = Number(v) || 0), + }); + const stopField = createInputField({ + label: TEXT.stop, + type: "number", + onInput: (v) => (stop = v === "" ? null : Number(v)), + }); + + modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => { + const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1; + const values = buildLinearSeries(start, step, stop, extent); + applyFillSeries(ctx, at, direction, values); + }); +} diff --git a/resources/tester/spreadsheet/test_stage2_data_tools.ts b/resources/tester/spreadsheet/test_stage2_data_tools.ts new file mode 100644 index 00000000..515c5f0e --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_data_tools.ts @@ -0,0 +1,113 @@ +/* ============================================================================= + * test_stage2_data_tools.ts — spreadsheet_data_tools 시험(텍스트 나누기 · 중복 제거 · 연속 채우기). + * 돌리기: npx tsx <이 파일> (`@ui/*` 별칭 때문에 plain node 안 됨) + * ========================================================================== */ + +import { + applyFillSeries, + applyTextToColumns, + buildLinearSeries, + findDuplicateRowIndexes, + removeDuplicateRows, + splitDelimited, + splitFixedWidth, +} from "../../../A00_Common/spreadsheet/spreadsheet_data_tools.ts"; +import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +// ── 텍스트 나누기 ────────────────────────────────────────────── +assertEqual(splitDelimited("가,나,다", ["쉼표"], ""), ["가", "나", "다"], "쉼표 나누기"); +assertEqual(splitDelimited("가\t나", ["탭"], ""), ["가", "나"], "탭 나누기"); +assertEqual( + splitDelimited("가;나,다", ["세미콜론", "쉼표"], ""), + ["가", "나", "다"], + "복수 구분 기호", +); +assertEqual(splitDelimited("가|나", ["사용자"], "|"), ["가", "나"], "사용자 지정 구분 기호"); +assertEqual(splitDelimited("가나다", [], ""), ["가나다"], "구분 기호 없으면 한 칸 그대로"); + +assertEqual(splitFixedWidth("2026-09-27", [4, 7]), ["2026", "-09", "-27"], "고정 폭 나누기"); +assertEqual(splitFixedWidth("ABC", [10]), ["ABC"], "자리가 글보다 길면 한 칸"); + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + applyTextToColumns(ctx, { r: 0, c: 0 }, [ + ["가", "나"], + ["1", "2"], + ]); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: "가" }, "나누기 결과 A1"); + assertEqual(book.시트[0].칸[a1(0, 1)], { 값: "나" }, "나누기 결과 B1"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 1 }, "나누기 결과 숫자는 수로"); + assertEqual(book.시트[0].칸[a1(1, 1)], { 값: 2 }, "나누기 결과 B2"); +} + +// ── 중복된 항목 제거 ────────────────────────────────────────── +assertEqual( + findDuplicateRowIndexes( + [ + ["가", "1"], + ["나", "2"], + ["가", "1"], + ["다", "3"], + ["나", "2"], + ], + [0, 1], + ), + [2, 4], + "중복 자리 찾기(모든 열 비교)", +); +assertEqual( + findDuplicateRowIndexes( + [ + ["가", "1"], + ["가", "9"], + ], + [0], + ), + [1], + "일부 열만 비교", +); + +{ + const book = makeBook([ + makeSheet("s1", { + [a1(0, 0)]: v("가"), + [a1(1, 0)]: v("나"), + [a1(2, 0)]: v("가"), + [a1(3, 0)]: v("다"), + }), + ]); + const ctx = makeCtx(book, "s1", { + s1: { A1: "가", A2: "나", A3: "가", A4: "다" }, + }); + const removed = removeDuplicateRows(ctx, { r0: 0, c0: 0, r1: 3, c1: 0 }, [0], false); + assertEqual(removed, 1, "지운 중복 행 수"); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: "가" }, "중복 제거 뒤 A1 그대로"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: "나" }, "중복 제거 뒤 A2 그대로"); + assertEqual(book.시트[0].칸[a1(2, 0)], { 값: "다" }, "중복 제거 뒤 위로 당김"); + assertEqual(book.시트[0].칸[a1(3, 0)], undefined, "중복 제거 뒤 마지막 칸 비움"); +} + +// ── 채우기 연속 데이터 ────────────────────────────────────────── +assertEqual(buildLinearSeries(1, 1, null, 5), [1, 2, 3, 4, 5], "단계 1 · 5개"); +assertEqual(buildLinearSeries(0, 2, 6, 100), [0, 2, 4, 6], "끝값에서 멈춤"); +assertEqual(buildLinearSeries(10, -2, 4, 100), [10, 8, 6, 4], "내림차순 끝값에서 멈춤"); + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + applyFillSeries(ctx, { r: 0, c: 0 }, "열", [1, 2, 3]); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "세로 채우기 A1"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 2 }, "세로 채우기 A2"); + assertEqual(book.시트[0].칸[a1(2, 0)], { 값: 3 }, "세로 채우기 A3"); +} +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + applyFillSeries(ctx, { r: 0, c: 0 }, "행", [1, 2]); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "가로 채우기 A1"); + assertEqual(book.시트[0].칸[a1(0, 1)], { 값: 2 }, "가로 채우기 B1"); +} + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_data_tools 시험 끝");