/* ============================================================================= * spreadsheet_functions_misc.ts (주인 B) * 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는 * `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27). * 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. * ========================================================================== */ import { ZERO, cmp, frac, mul, parseDecimal, roundAt, toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types"; import { formatValue } from "./spreadsheet_numfmt"; import { bool, fracToDouble, fromDouble, isErr, isFrac, isRange, mkErr, num, str, toBool, toScalar, toText, } from "./spreadsheet_functions_math"; const ONE = frac(1n); /** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */ function wildcardToSearchRegex(text: string): RegExp { const escaped = text .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); return new RegExp(escaped, "i"); } // ── 숫자 → 고정 소수 글(FIXED) ─────────────────────────────────────────────── function groupThousands(digits: string): string { return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function formatFixed(n: Frac, decimals: number, commas: boolean): string { const d = Math.max(0, decimals); const rounded = roundAt(n, d, "round"); const negative = cmp(rounded, ZERO) < 0; const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded; const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round"); const digits = scaled.toString().padStart(d + 1, "0"); let whole = d > 0 ? digits.slice(0, -d) : digits; const fraction = d > 0 ? digits.slice(-d) : ""; if (commas) whole = groupThousands(whole); const body = fraction ? `${whole}.${fraction}` : whole; return negative ? `-${body}` : body; } function collectBools(args: LazyArg[]): boolean[] | ErrorValue { const out: boolean[] = []; for (const a of args) { const v = a(); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { for (let c = 0; c < v.cols; c++) { const cell = v.at(r, c); if (isErr(cell)) return cell; if (typeof cell === "boolean") out.push(cell); else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0); } } } else if (isErr(v)) { return v; } else if (v !== null) { const b = toBool(v); if (isErr(b)) return b; out.push(b); } } return out; } // ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ─────────────────────── // 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) — // 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖). const MS_PER_DAY = 86400000; const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569; function serialToDate(serial: number): Date { return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY)); } function dateToSerial(d: Date): number { return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL; } // ── 함수 표 ────────────────────────────────────────────────────────────── export const MISC_FUNCTIONS: Record = { IF: { min: 2, max: 3, call: (args) => { const c = bool(args[0]()); if (isErr(c)) return c; if (c) return args[1](); return args.length > 2 ? args[2]() : false; }, }, AND: { min: 1, call: (args) => { const bs = collectBools(args); if (isErr(bs)) return bs; if (bs.length === 0) return mkErr("#VALUE!"); return bs.every(Boolean); }, }, OR: { min: 1, call: (args) => { const bs = collectBools(args); if (isErr(bs)) return bs; if (bs.length === 0) return mkErr("#VALUE!"); return bs.some(Boolean); }, }, NOT: { min: 1, max: 1, call: (args) => { const b = bool(args[0]()); return isErr(b) ? b : !b; }, }, IFERROR: { min: 2, max: 2, call: (args) => { const v = args[0](); if (!isRange(v) && isErr(v)) return args[1](); return v; }, }, CONCATENATE: { min: 1, call: (args) => { let out = ""; for (const a of args) { const v = a(); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { for (let c = 0; c < v.cols; c++) { const t = toText(v.at(r, c)); if (isErr(t)) return t; out += t; } } } else { const t = str(v); if (isErr(t)) return t; out += t; } } return out; }, }, FIXED: { min: 1, max: 3, call: (args) => { const n = num(args[0]()); if (isErr(n)) return n; const decArg = args.length > 1 ? num(args[1]()) : frac(2n); if (isErr(decArg)) return decArg; const decimals = Math.trunc(fracToDouble(decArg)); const noCommaArg = args.length > 2 ? bool(args[2]()) : false; if (isErr(noCommaArg)) return noCommaArg; return formatFixed(n, decimals, !noCommaArg); }, }, TEXT: { min: 2, max: 2, call: (args) => { const v = toScalar(args[0]()); if (isErr(v)) return v; const code = str(args[1]()); if (isErr(code)) return code; return formatValue(v, code).글; }, }, LEN: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : frac(BigInt(s.length)); }, }, LEFT: { min: 1, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(nArg)) return nArg; const n = Math.max(0, Math.trunc(fracToDouble(nArg))); return s.slice(0, n); }, }, RIGHT: { min: 1, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(nArg)) return nArg; const n = Math.max(0, Math.trunc(fracToDouble(nArg))); return n === 0 ? "" : s.slice(-n); }, }, MID: { min: 3, max: 3, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const startN = num(args[1]()); if (isErr(startN)) return startN; const lenN = num(args[2]()); if (isErr(lenN)) return lenN; const start = Math.trunc(fracToDouble(startN)); const len = Math.max(0, Math.trunc(fracToDouble(lenN))); if (start < 1) return mkErr("#VALUE!"); return s.slice(start - 1, start - 1 + len); }, }, VALUE: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const trimmed = s.trim(); const pct = trimmed.endsWith("%"); const body = pct ? trimmed.slice(0, -1) : trimmed; try { const v = parseDecimal(body); return pct ? { n: v.n, d: v.d * 100n } : v; } catch { return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`); } }, }, TEXTJOIN: { min: 3, call: (args) => { const delim = str(args[0]()); if (isErr(delim)) return delim; const ignoreEmpty = bool(args[1]()); if (isErr(ignoreEmpty)) return ignoreEmpty; const parts: string[] = []; for (let i = 2; i < args.length; i++) { const v = args[i](); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { for (let c = 0; c < v.cols; c++) { const t = toText(v.at(r, c)); if (isErr(t)) return t; if (!(ignoreEmpty && t === "")) parts.push(t); } } } else { const t = str(v); if (isErr(t)) return t; if (!(ignoreEmpty && t === "")) parts.push(t); } } return parts.join(delim); }, }, TRIM: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : s.split(" ").filter(Boolean).join(" "); }, }, UPPER: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : s.toUpperCase(); }, }, LOWER: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : s.toLowerCase(); }, }, SUBSTITUTE: { min: 3, max: 4, call: (args) => { const text = str(args[0]()); if (isErr(text)) return text; const oldT = str(args[1]()); if (isErr(oldT)) return oldT; const newT = str(args[2]()); if (isErr(newT)) return newT; if (oldT === "") return text; if (args.length <= 3) return text.split(oldT).join(newT); const nthArg = num(args[3]()); if (isErr(nthArg)) return nthArg; const nth = Math.trunc(fracToDouble(nthArg)); let idx = -1; let count = 0; let pos = 0; for (;;) { const found = text.indexOf(oldT, pos); if (found < 0) break; count++; if (count === nth) { idx = found; break; } pos = found + oldT.length; } return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length); }, }, REPLACE: { min: 4, max: 4, call: (args) => { const text = str(args[0]()); if (isErr(text)) return text; const startN = num(args[1]()); if (isErr(startN)) return startN; const lenN = num(args[2]()); if (isErr(lenN)) return lenN; const newText = str(args[3]()); if (isErr(newText)) return newText; const start = Math.trunc(fracToDouble(startN)); const len = Math.max(0, Math.trunc(fracToDouble(lenN))); if (start < 1) return mkErr("#VALUE!"); return text.slice(0, start - 1) + newText + text.slice(start - 1 + len); }, }, FIND: { min: 2, max: 3, call: (args) => { const findT = str(args[0]()); if (isErr(findT)) return findT; const within = str(args[1]()); if (isErr(within)) return within; const startArg = args.length > 2 ? num(args[2]()) : ONE; if (isErr(startArg)) return startArg; const start = Math.trunc(fracToDouble(startArg)); if (start < 1) return mkErr("#VALUE!"); const idx = within.indexOf(findT, start - 1); return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1)); }, }, SEARCH: { min: 2, max: 3, call: (args) => { const findT = str(args[0]()); if (isErr(findT)) return findT; const within = str(args[1]()); if (isErr(within)) return within; const startArg = args.length > 2 ? num(args[2]()) : ONE; if (isErr(startArg)) return startArg; const start = Math.trunc(fracToDouble(startArg)); if (start < 1) return mkErr("#VALUE!"); const pat = wildcardToSearchRegex(findT); const m = pat.exec(within.slice(start - 1)); return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!"); }, }, REPT: { min: 2, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = num(args[1]()); if (isErr(nArg)) return nArg; const n = Math.trunc(fracToDouble(nArg)); return n < 0 ? mkErr("#VALUE!") : s.repeat(n); }, }, ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) }, ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" }, ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null }, ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) }, NA: { min: 0, max: 0, call: () => mkErr("#N/A") }, TODAY: { min: 0, max: 0, call: () => { const now = new Date(); const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); return fromDouble(dateToSerial(new Date(midnight))); }, }, DATE: { min: 3, max: 3, call: (args) => { const y = num(args[0]()); if (isErr(y)) return y; const m = num(args[1]()); if (isErr(m)) return m; const d = num(args[2]()); if (isErr(d)) return d; const date = new Date( Date.UTC( Math.trunc(fracToDouble(y)), Math.trunc(fracToDouble(m)) - 1, Math.trunc(fracToDouble(d)), ), ); return fromDouble(dateToSerial(date)); }, }, YEAR: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear())); }, }, MONTH: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1)); }, }, DAY: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate())); }, }, };