Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
452 lines
18 KiB
TypeScript
452 lines
18 KiB
TypeScript
/* =============================================================================
|
||
* M01_SL_Table_Build.ts
|
||
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
|
||
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts`
|
||
*
|
||
* 시트 모양(표 상자 시트 하나):
|
||
* A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움
|
||
* 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로)
|
||
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
|
||
* 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A
|
||
* ========================================================================== */
|
||
|
||
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
|
||
|
||
type Rec = Record<string, string | number | [number | null, number | null] | null | undefined>;
|
||
|
||
/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */
|
||
export interface TblInfo {
|
||
key: string;
|
||
name: string;
|
||
/** 기준 「1㎡」 */
|
||
base: string;
|
||
source: string;
|
||
/** 원문번호 「13-4-1」 */
|
||
no: string;
|
||
/** 조건 칸 → 「수」 「고르기」 「범위」 */
|
||
conds: Record<string, string>;
|
||
/** 값 칸 → 단위 */
|
||
vals: Record<string, string>;
|
||
rule: string | null;
|
||
rows: Rec[];
|
||
notes: string[];
|
||
}
|
||
|
||
/** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */
|
||
export interface TblRoles {
|
||
cond: string[];
|
||
out: string[];
|
||
}
|
||
|
||
/** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */
|
||
export interface TblCol {
|
||
header: string;
|
||
of: string;
|
||
part: "값" | "아래" | "위";
|
||
}
|
||
|
||
export interface TblPlan {
|
||
cols: TblCol[];
|
||
/** 블록 안 줄 수(머리 줄 빼고) */
|
||
rowCount: number;
|
||
/** 블록 밖에 쓸 칸 — A1 → 칸 */
|
||
cells: Record<string, Cell>;
|
||
/** 조건 칸 이름 → 입력 칸 */
|
||
inCells: Record<string, string>;
|
||
/** 결과 칸 이름 → 출력 칸 */
|
||
outCells: Record<string, string>;
|
||
idxCell: string;
|
||
statusCell: string;
|
||
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */
|
||
helperFrom: number;
|
||
warnings: string[];
|
||
}
|
||
|
||
/** 열 글자 0 → A */
|
||
export const colName = (c: number): string => {
|
||
let s = "";
|
||
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
|
||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||
return s;
|
||
};
|
||
const colIndex = (letters: string): number =>
|
||
[...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1;
|
||
const at = (r: number, c: number): string => `${colName(c)}${r}`;
|
||
|
||
const MARGIN = 20;
|
||
const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`;
|
||
|
||
/** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */
|
||
export const isSplit = (info: TblInfo, c: string): boolean =>
|
||
info.conds[c] === "범위" || (c in info.vals && info.rows.some((r) => Array.isArray(r[c])));
|
||
|
||
/** 블록 열 차례 — 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 둘로(서버 `_table` 과 같은 차례) */
|
||
export function layoutOf(info: TblInfo): TblCol[] {
|
||
const out: TblCol[] = [];
|
||
for (const c of [...Object.keys(info.conds), ...Object.keys(info.vals)])
|
||
if (isSplit(info, c))
|
||
out.push(
|
||
{ header: `${c}·아래`, of: c, part: "아래" },
|
||
{ header: `${c}·위`, of: c, part: "위" },
|
||
);
|
||
else out.push({ header: c, of: c, part: "값" });
|
||
return out;
|
||
}
|
||
|
||
const cellOf = (v: Rec[string]): Cell | null =>
|
||
v === null || v === undefined || v === ""
|
||
? null
|
||
: typeof v === "number"
|
||
? { 값: v }
|
||
: { 값: String(v) };
|
||
|
||
/** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */
|
||
export function blockCells(info: TblInfo): Record<string, Cell> {
|
||
const cells: Record<string, Cell> = {};
|
||
const cols = layoutOf(info);
|
||
cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header }));
|
||
info.rows.forEach((row, i) =>
|
||
cols.forEach((col, c) => {
|
||
const v = row[col.of];
|
||
// 범위 칸에 숫자 하나면 아래 · 위에 같은 수
|
||
const x = Array.isArray(v) ? (col.part === "아래" ? v[0] : v[1]) : v;
|
||
const cell = cellOf(x);
|
||
if (cell) cells[at(i + 2, c)] = cell;
|
||
}),
|
||
);
|
||
return cells;
|
||
}
|
||
|
||
/** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */
|
||
const sampleOf = (info: TblInfo, c: string): string | number => {
|
||
const v = info.rows[0]?.[c];
|
||
if (Array.isArray(v))
|
||
return v[0] !== null && v[1] !== null
|
||
? Number(((v[0] + v[1]) / 2).toFixed(6))
|
||
: (v[1] ?? v[0] ?? 0);
|
||
return v === null || v === undefined ? "" : v;
|
||
};
|
||
|
||
/** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */
|
||
export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||
const cols = layoutOf(info);
|
||
const warnings: string[] = [];
|
||
const W = cols.length;
|
||
const n = info.rows.length;
|
||
const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header));
|
||
const conds = roles.cond.filter((c) => c in info.conds);
|
||
const rangeCols = conds.filter((c) => info.conds[c] === "범위");
|
||
const exact = conds.filter((c) => info.conds[c] !== "범위");
|
||
const range = rangeCols[0];
|
||
if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌");
|
||
const blankable = exact.filter((c) =>
|
||
info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""),
|
||
);
|
||
if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원");
|
||
if (range && blankable.length)
|
||
warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음");
|
||
const rule = info.rule ?? "초과·이하";
|
||
const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule));
|
||
const R = (range ? n : n + MARGIN) + 1;
|
||
const rng = (L: string): string => `${L}$2:${L}$${R}`;
|
||
|
||
// 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
|
||
const variants: string[][] = [[]];
|
||
if (!range) {
|
||
const bl = blankable.slice(0, 2);
|
||
const subsets =
|
||
bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : [];
|
||
variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음
|
||
}
|
||
const keyCols = exact.length ? variants : [];
|
||
const kStart = W + 1;
|
||
const kLetters = keyCols.map((_, i) => colName(kStart + i));
|
||
const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열
|
||
const V = L + 1;
|
||
const cells: Record<string, Cell> = {};
|
||
keyCols.forEach((skip, v) => {
|
||
cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" };
|
||
const rest = exact.filter((c) => !skip.includes(c));
|
||
for (let r = 2; r <= R; r++) {
|
||
const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&');
|
||
if (!skip.length) cells[at(r, kStart + v)] = { 식: key };
|
||
else {
|
||
const guard = [
|
||
...skip.map((c) => `${letter(c)}${r}=""`),
|
||
...rest.map((c) => `${letter(c)}${r}<>""`),
|
||
].join(",");
|
||
cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` };
|
||
}
|
||
}
|
||
});
|
||
|
||
// 찾기 판
|
||
let row = 1;
|
||
cells[at(row++, L)] = { 값: "표에서 찾기" };
|
||
const inCells: Record<string, string> = {};
|
||
const inputOf: Record<string, string> = {};
|
||
for (const c of conds) {
|
||
cells[at(row, L)] = { 값: c };
|
||
const sample = sampleOf(info, c);
|
||
if (sample !== "") cells[at(row, V)] = { 값: sample };
|
||
inCells[c] = at(row, V);
|
||
inputOf[c] = at(row, V);
|
||
row++;
|
||
}
|
||
const keyOf = (skip: string[]): string =>
|
||
exact
|
||
.filter((c) => !skip.includes(c))
|
||
.map((c) => inputOf[c])
|
||
.join('&"|"&');
|
||
const idxRow = (label: string, f: string): string => {
|
||
cells[at(row, L)] = { 값: label };
|
||
cells[at(row, V)] = { 식: f };
|
||
return at(row++, V);
|
||
};
|
||
const up = range ? rng(letter(`${range}·위`)) : "";
|
||
const lo = range ? rng(letter(`${range}·아래`)) : "";
|
||
const x = range ? inputOf[range] : "";
|
||
let idx = "";
|
||
let count = "";
|
||
if (exact.length) {
|
||
const keyCell = idxRow("열쇠", keyOf([]));
|
||
const ks = keyCols.map((skip, v) => {
|
||
if (v === 0) return keyCell;
|
||
cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` };
|
||
cells[at(row, V)] = { 식: keyOf(skip) };
|
||
return at(row++, V);
|
||
});
|
||
if (range) {
|
||
idx = idxRow(
|
||
"걸린 줄",
|
||
`MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`,
|
||
);
|
||
count = idxRow(
|
||
"일치 수",
|
||
`IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
|
||
);
|
||
} else {
|
||
const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`);
|
||
count = idxRow(
|
||
"일치 수",
|
||
found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"),
|
||
);
|
||
const chain = ks
|
||
.map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`)
|
||
.reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()");
|
||
idx = idxRow("걸린 줄", chain);
|
||
}
|
||
} else if (range) {
|
||
idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`);
|
||
const inside = lowBased
|
||
? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<INDEX(${up},${idx})))`
|
||
: `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`;
|
||
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`);
|
||
} else {
|
||
warnings.push("조건 칸이 없음 — 찾을 줄이 없음");
|
||
count = idxRow("일치 수", "0");
|
||
idx = idxRow("걸린 줄", "NA()");
|
||
}
|
||
if (lowBased && range && exact.length)
|
||
warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음");
|
||
const statusCell = idxRow(
|
||
"상태",
|
||
`IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`,
|
||
);
|
||
const outCells: Record<string, string> = {};
|
||
for (const c of roles.out.filter((o) => o in info.vals)) {
|
||
for (const h of isSplit(info, c) ? [`${c}·아래`, `${c}·위`] : [c])
|
||
outCells[h] = idxRow(
|
||
h,
|
||
`IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(h))},${idx}),NA())`,
|
||
);
|
||
}
|
||
return {
|
||
cols,
|
||
rowCount: n,
|
||
cells,
|
||
inCells,
|
||
outCells,
|
||
idxCell: idx,
|
||
statusCell,
|
||
helperFrom: W,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
/** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */
|
||
export function pillsOf(
|
||
info: TblInfo,
|
||
plan: TblPlan,
|
||
outside: boolean,
|
||
): { pillsIn: SlPill[]; pillsOut: SlPill[] } {
|
||
const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell }));
|
||
const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({
|
||
name,
|
||
cell,
|
||
unit: info.vals[name.replace(/·(아래|위)$/, "")] || undefined,
|
||
}));
|
||
if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true });
|
||
return { pillsIn, pillsOut };
|
||
}
|
||
|
||
const sheetIndexOf = (sl: SheetLogic, node: SlNode): number =>
|
||
sl.workbook.시트.findIndex((s) => s.id === node.sheetId);
|
||
|
||
/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */
|
||
export function applyTable(
|
||
sl: SheetLogic,
|
||
nodeId: string,
|
||
info: TblInfo,
|
||
roles: TblRoles,
|
||
opts: { ref: string; fillBlock: boolean; outside: boolean },
|
||
): { sl: SheetLogic; plan: TblPlan } {
|
||
const next = structuredClone(sl);
|
||
const node = next.nodes.find((n) => n.id === nodeId);
|
||
if (!node) throw new Error("없는 상자");
|
||
const si = sheetIndexOf(next, node);
|
||
if (si < 0) throw new Error("상자의 시트가 없음");
|
||
const plan = planLookup(info, roles);
|
||
const sheet: Sheet = next.workbook.시트[si];
|
||
// 이미 있던 입력 칸 내용(예시값 · 선을 그은 참조 수식)은 같은 이름이면 그대로 가져감
|
||
const oldIn = Object.fromEntries(node.pillsIn.map((p) => [p.name, sheet.칸[p.cell]]));
|
||
const keep = Object.entries(sheet.칸).filter(
|
||
([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom,
|
||
);
|
||
sheet.칸 = Object.fromEntries(keep);
|
||
if (opts.fillBlock) {
|
||
for (const a of Object.keys(sheet.칸)) delete sheet.칸[a];
|
||
Object.assign(sheet.칸, blockCells(info));
|
||
}
|
||
Object.assign(sheet.칸, plan.cells);
|
||
for (const [name, cell] of Object.entries(plan.inCells))
|
||
if (oldIn[name]) sheet.칸[cell] = oldIn[name];
|
||
const pills = pillsOf(info, plan, opts.outside);
|
||
const gone = (dir: "in" | "out", name: string): boolean =>
|
||
!(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name);
|
||
next.edges = next.edges.filter(
|
||
(e) =>
|
||
!(e.from.node === nodeId && gone("out", e.from.pill)) &&
|
||
!(e.to.node === nodeId && gone("in", e.to.pill)),
|
||
);
|
||
node.name = `표 · ${info.name}`;
|
||
node.ref = opts.ref;
|
||
node.pillsIn = pills.pillsIn;
|
||
node.pillsOut = pills.pillsOut;
|
||
return { sl: next, plan };
|
||
}
|
||
|
||
/** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */
|
||
export function parsePaste(text: string): { info: TblInfo; error: string | null } {
|
||
const lines = text
|
||
.replace(/\r/g, "")
|
||
.split("\n")
|
||
.filter((l) => l.trim() !== "");
|
||
const empty: TblInfo = {
|
||
key: "",
|
||
name: "",
|
||
base: "",
|
||
source: "",
|
||
no: "",
|
||
conds: {},
|
||
vals: {},
|
||
rule: null,
|
||
rows: [],
|
||
notes: [],
|
||
};
|
||
if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" };
|
||
const grid = lines.map((l) => l.split("\t").map((c) => c.trim()));
|
||
const head = grid[0];
|
||
if (head.some((h) => h === "") || new Set(head).size !== head.length)
|
||
return { info: empty, error: "머리 칸 이름이 비었거나 겹침" };
|
||
const num = (s: string): number | null =>
|
||
s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null;
|
||
const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/;
|
||
const body = grid.slice(1);
|
||
const isRange = (c: number): boolean =>
|
||
body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) &&
|
||
body.every((r) => !r[c] || rangeRe.test(r[c]));
|
||
const rows: Rec[] = body.map((r) => {
|
||
const rec: Rec = {};
|
||
head.forEach((h, c) => {
|
||
const s = r[c] ?? "";
|
||
if (isRange(c)) {
|
||
const m = rangeRe.exec(s);
|
||
rec[h] = m
|
||
? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])]
|
||
: null;
|
||
} else rec[h] = s === "" ? null : (num(s) ?? s);
|
||
});
|
||
return rec;
|
||
});
|
||
// 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값
|
||
const conds: Record<string, string> = {};
|
||
const vals: Record<string, string> = {};
|
||
head.forEach((h, c) => {
|
||
const numeric = body.every((r) => !r[c] || num(r[c]) !== null);
|
||
if (isRange(c)) conds[h] = "범위";
|
||
else if (!numeric) conds[h] = "고르기";
|
||
else if (c === 0 && head.length > 1) conds[h] = "수";
|
||
else vals[h] = "";
|
||
});
|
||
if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" };
|
||
return { info: { ...empty, conds, vals, rows }, error: null };
|
||
}
|
||
|
||
/** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */
|
||
export function infoFromSheet(sheet: Sheet, prev: Pick<TblInfo, "conds" | "name">): TblInfo {
|
||
const heads: string[] = [];
|
||
for (let c = 0; ; c++) {
|
||
const h = sheet.칸[at(1, c)]?.값;
|
||
if (h === undefined || h === "") break;
|
||
heads.push(String(h));
|
||
}
|
||
let last = 1;
|
||
for (const a of Object.keys(sheet.칸)) {
|
||
const m = /^([A-Z]+)(\d+)$/.exec(a);
|
||
if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2]));
|
||
}
|
||
const conds: Record<string, string> = {};
|
||
const vals: Record<string, string> = {};
|
||
const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = [];
|
||
heads.forEach((h, c) => {
|
||
const m = /^(.+)·(아래|위)$/.exec(h);
|
||
names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c });
|
||
});
|
||
for (const nm of names) {
|
||
if (nm.part !== "값") {
|
||
if (prev.conds[nm.of]) conds[nm.of] = "범위";
|
||
else vals[nm.of] = "";
|
||
} else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of];
|
||
else vals[nm.of] = "";
|
||
}
|
||
const rows: Rec[] = [];
|
||
for (let r = 2; r <= last; r++) {
|
||
const rec: Rec = {};
|
||
for (const nm of names) {
|
||
const v = sheet.칸[at(r, nm.c)]?.값;
|
||
const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v;
|
||
if (nm.part === "값") rec[nm.of] = x;
|
||
else {
|
||
const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null];
|
||
prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null;
|
||
rec[nm.of] = prevPair;
|
||
}
|
||
}
|
||
rows.push(rec);
|
||
}
|
||
return {
|
||
key: "",
|
||
name: prev.name,
|
||
base: "",
|
||
source: "",
|
||
no: "",
|
||
conds,
|
||
vals,
|
||
rule: null,
|
||
rows,
|
||
notes: [],
|
||
};
|
||
}
|