Files
losadev-tools/app/public/legacy/js/tables.js
T

769 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Tables tab (DAT Editor): the client's .dat tables (resource/tables in the packs) and the server's
// (ls_gamesvr\config\tables) as a spreadsheet, read and written by src/main/dattable.js. The columns
// come from the file and can't change (the game checks the header version); rows and values can.
// Grid: click / drag / shift to select, type or F2 / double-click to edit, Enter / Tab to move,
// Ctrl+C / Ctrl+V with Excel (tab-separated), Delete clears, Ctrl+Z / Ctrl+Y, Ctrl+S saves.
(() => {
const T = { lists: { client: [], server: [] }, cur: null, meta: null, busy: false };
window.DT = T; // (dev probes)
const LS = (k, v) => {
try {
if (v === undefined) return localStorage.getItem(k) || "";
localStorage.setItem(k, v);
} catch {}
return "";
};
const RH = 26;
// ---------- table list ----------
const clientSrc = () => T.src || profileSource();
async function listTables() {
const src = clientSrc();
const server = LS("dt:server");
try {
T.lists.client = src ? await call("dat:client", src) : [];
} catch (err) {
T.lists.client = [];
toast(err.message, true);
}
T.lists.server = server ? await call("dat:folder", server) : [];
$("dt-source").textContent = [src ? `client: ${src.folder}` : "no game folder in this profile", server ? `server: ${server}` : "no server folder"].join(" · ");
renderList();
}
function renderList() {
const group = (title, items, kind, empty) => [
el("div", { class: "dt-group", text: title }),
...(items.length
? items.map((it) =>
el(
"button",
{
class: `dt-item${T.cur && T.cur.refKey === `${kind}|${it.key}` ? " on" : ""}`,
title: it.path,
onclick: () => openTable(kind === "client" ? { kind: "pack", src: clientSrc(), key: it.key } : { kind: "file", file: it.path }, `${kind}|${it.key}`),
},
[el("span", { text: it.name.replace(/\.dat$/i, "") }), kind === "client" && it.pack ? el("small", { class: "muted", text: it.pack }) : null],
),
)
: [el("div", { class: "dt-none muted", text: empty })]),
];
const opened = T.cur && T.cur.ref.kind === "file" && !T.lists.server.some((x) => x.path === T.cur.ref.file) ? group("File", [{ key: T.cur.ref.file, name: T.cur.ref.file.split(/[\\/]/).pop(), path: T.cur.ref.file }], "file", "") : [];
$("dt-list").replaceChildren(...group("Client (packs)", T.lists.client, "client", "No resource/tables/*.dat in the packs."), ...group("Server", T.lists.server, "server", "Pick the server's tables folder."), ...opened);
}
// ---------- opening ----------
async function leave() {
if (!T.cur?.dirty()) return true;
return confirmBox("Unsaved changes", `${T.cur.name} has changes that aren't saved. Drop them?`, "Drop changes", "danger");
}
async function openTable(ref, refKey, { encoding = "auto" } = {}) {
if (T.cur && T.cur.refKey !== refKey && !(await leave())) return;
let d;
try {
d = await call("dat:open", ref, encoding);
} catch (err) {
return toast(err.message, true);
}
if (T.cur) call("dat:close", T.cur.id);
const folder = LS("dt:maker");
let meta = null;
try {
meta = folder ? await call("dat:meta", folder, d.name) : null;
} catch {}
T.cur = new Sheet(d, ref, refKey, meta);
$("dt-enc").value = d.encoding;
$("dt-search").value = "";
renderList();
T.cur.mount($("dt-grid"));
status();
}
// ---------- the sheet ----------
class Sheet {
constructor(d, ref, refKey, meta) {
this.id = d.id;
this.ref = ref;
this.refKey = refKey;
this.name = d.name;
this.info = d;
this.meta = meta;
this.cols = d.cols.map((c) => ({ ...c, meta: meta?.cols?.[c.name] || null, enums: meta?.enums?.[c.name] || null }));
this.setData(d.rows);
this.undo = [];
this.redo = [];
this.sort = null;
this.filter = "";
this.act = { r: 0, c: 0 };
this.anchor = { r: 0, c: 0 };
this.widths = this.cols.map((c, i) => this.guessWidth(c, i));
}
setData(rows) {
this.orig = rows;
this.rows = rows.map((v, o) => ({ o, v: [...v] }));
this.view = this.rows.map((_, i) => i);
}
guessWidth(c, i) {
let w = c.name.length * 7.5 + 26;
for (const r of this.rows.slice(0, 200)) w = Math.max(w, String(r.v[i]).length * 7 + 18);
return Math.round(Math.min(Math.max(w, 56), c.text ? 320 : 150));
}
dirty() {
if (this.rows.length !== this.orig.length) return true;
return this.rows.some((r, i) => r.o !== i || r.v.some((v, c) => v !== this.orig[i][c]));
}
cellDirty(row, c) {
return row.o < 0 || row.v[c] !== this.orig[row.o]?.[c];
}
// ----- view -----
applyView() {
const q = this.filter.toLowerCase();
let idx = this.rows.map((_, i) => i);
if (q) idx = idx.filter((i) => this.rows[i].v.some((v) => String(v).toLowerCase().includes(q)));
if (this.sort) {
const { c, desc } = this.sort;
const num = !this.cols[c].text;
idx.sort((a, b) => {
const x = this.rows[a].v[c];
const y = this.rows[b].v[c];
const r = num ? Number(x) - Number(y) : String(x).localeCompare(String(y));
return (desc ? -r : r) || a - b;
});
}
this.view = idx;
this.act.r = Math.min(this.act.r, Math.max(0, idx.length - 1));
this.anchor = { ...this.act };
}
// ----- DOM -----
mount(host) {
this.host = host;
host.replaceChildren();
host.tabIndex = 0;
this.head = el("div", { class: "dt-head" });
this.body = el("div", { class: "dt-rows" });
host.append(this.head, this.body);
host.onscroll = () => this.draw();
host.onkeydown = (e) => this.key(e);
host.onmousedown = (e) => this.mouse(e);
host.ondblclick = (e) => {
const cell = e.target.closest(".dt-cell");
if (cell) this.edit();
};
host.oncopy = (e) => this.copy(e);
host.onpaste = (e) => this.paste(e);
this.drawHead();
this.layout();
host.focus();
}
colLeft(c) {
let x = 56;
for (let i = 0; i < c; i++) x += this.widths[i];
return x;
}
layout() {
const total = this.colLeft(this.cols.length);
this.head.style.width = `${total}px`;
this.body.style.width = `${total}px`;
this.body.style.height = `${this.view.length * RH}px`;
this.draw();
}
drawHead() {
const cells = [el("div", { class: "dt-h dt-rn", text: "#", style: "width:56px" })];
this.cols.forEach((c, i) => {
const tip = [`${c.name} — ${c.typeName}`, c.meta?.desc, c.meta?.export ? `used by: ${c.meta.export}` : null, c.enums ? `enum: ${c.enums.map((e) => `${e.value} ${e.name}`).join(", ")}` : null].filter(Boolean).join("\n");
const arrow = this.sort?.c === i ? (this.sort.desc ? " ▼" : " ▲") : "";
const h = el("div", { class: "dt-h", title: tip, style: `width:${this.widths[i]}px` }, [
el("b", { text: c.name + arrow }),
el("small", { text: c.meta?.desc ? `${c.typeName} · ${c.meta.desc}` : c.typeName }),
el("i", { class: "dt-resize", onmousedown: (e) => this.resize(e, i) }),
]);
h.addEventListener("click", (e) => {
if (e.target.classList.contains("dt-resize")) return;
this.sort = this.sort?.c !== i ? { c: i, desc: false } : this.sort.desc ? null : { c: i, desc: true };
this.applyView();
this.drawHead();
this.layout();
status();
});
cells.push(h);
});
this.head.replaceChildren(...cells);
}
resize(e, i) {
e.preventDefault();
e.stopPropagation();
const x0 = e.clientX;
const w0 = this.widths[i];
const move = (ev) => {
this.widths[i] = Math.max(40, w0 + ev.clientX - x0);
this.drawHead();
this.layout();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
sel() {
return { r0: Math.min(this.act.r, this.anchor.r), r1: Math.max(this.act.r, this.anchor.r), c0: Math.min(this.act.c, this.anchor.c), c1: Math.max(this.act.c, this.anchor.c) };
}
display(c, v) {
if (c.enums && v !== "") {
const e = c.enums.find((x) => String(x.value) === String(v));
if (e) return [el("span", { text: v }), el("small", { class: "dt-enum", text: e.name })];
}
return [document.createTextNode(v)];
}
draw() {
if (!this.host) return;
const top = this.host.scrollTop - 34;
const h = this.host.clientHeight;
const first = Math.max(0, Math.floor(top / RH) - 4);
const last = Math.min(this.view.length, Math.ceil((top + h) / RH) + 4);
const s = this.sel();
const frag = document.createDocumentFragment();
for (let vr = first; vr < last; vr++) {
const row = this.rows[this.view[vr]];
const line = el("div", { class: `dt-row${row.o < 0 ? " new" : ""}`, style: `top:${vr * RH}px` });
line.append(el("div", { class: "dt-cell dt-rn", text: String(this.view[vr] + 1), style: "width:56px", "data-r": vr, "data-c": -1 }));
this.cols.forEach((c, ci) => {
const inSel = vr >= s.r0 && vr <= s.r1 && ci >= s.c0 && ci <= s.c1;
const cls = ["dt-cell", c.text ? "txt" : "num", this.cellDirty(row, ci) ? "dirty" : "", inSel ? "sel" : "", vr === this.act.r && ci === this.act.c ? "act" : ""].filter(Boolean).join(" ");
line.append(el("div", { class: cls, style: `width:${this.widths[ci]}px`, "data-r": vr, "data-c": ci }, this.display(c, row.v[ci])));
});
frag.append(line);
}
this.body.replaceChildren(frag);
}
scrollTo(vr, c) {
const y = vr * RH;
const hh = 34;
if (y < this.host.scrollTop) this.host.scrollTop = y;
else if (y + RH > this.host.scrollTop + this.host.clientHeight - hh) this.host.scrollTop = y + RH - this.host.clientHeight + hh;
if (c >= 0) {
const x = this.colLeft(c);
const w = this.widths[c];
if (x - 56 < this.host.scrollLeft) this.host.scrollLeft = x - 56;
else if (x + w > this.host.scrollLeft + this.host.clientWidth) this.host.scrollLeft = x + w - this.host.clientWidth;
}
}
// ----- selection -----
cellAt(e) {
const cell = e.target.closest?.(".dt-cell");
if (!cell) return null;
return { r: Number(cell.dataset.r), c: Number(cell.dataset.c) };
}
mouse(e) {
if (e.button !== 0 || e.target.closest(".dt-h") || e.target.closest(".dt-editor")) return;
const at = this.cellAt(e);
if (!at) return;
this.commitEdit();
if (at.c < 0) {
// row number: whole rows
if (!e.shiftKey) this.anchor = { r: at.r, c: 0 };
this.act = { r: at.r, c: this.cols.length - 1 };
this.anchor.c = 0;
} else {
this.act = at;
if (!e.shiftKey) this.anchor = { ...at };
}
this.draw();
status();
const move = (ev) => {
const t = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.(".dt-cell");
if (!t || !this.host.contains(t)) return;
const r = Number(t.dataset.r);
const c = Number(t.dataset.c);
if (r === this.act.r && (c === this.act.c || c < 0)) return;
this.act = { r, c: c < 0 ? this.act.c : c };
this.draw();
status();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
move(dr, dc, extend) {
this.act = { r: Math.max(0, Math.min(this.view.length - 1, this.act.r + dr)), c: Math.max(0, Math.min(this.cols.length - 1, this.act.c + dc)) };
if (!extend) this.anchor = { ...this.act };
this.scrollTo(this.act.r, this.act.c);
this.draw();
status();
}
key(e) {
if (this.editor) return;
const ctrl = e.ctrlKey || e.metaKey;
const k = e.key;
const page = Math.max(1, Math.floor(this.host.clientHeight / RH) - 2);
if (k === "ArrowDown") this.move(ctrl ? this.view.length : 1, 0, e.shiftKey);
else if (k === "ArrowUp") this.move(ctrl ? -this.view.length : -1, 0, e.shiftKey);
else if (k === "ArrowRight") this.move(0, ctrl ? this.cols.length : 1, e.shiftKey);
else if (k === "ArrowLeft") this.move(0, ctrl ? -this.cols.length : -1, e.shiftKey);
else if (k === "PageDown") this.move(page, 0, e.shiftKey);
else if (k === "PageUp") this.move(-page, 0, e.shiftKey);
else if (k === "Home") this.move(ctrl ? -this.view.length : 0, -this.cols.length, e.shiftKey);
else if (k === "End") this.move(ctrl ? this.view.length : 0, this.cols.length, e.shiftKey);
else if (k === "Tab") this.move(0, e.shiftKey ? -1 : 1, false);
else if (k === "Enter" && ctrl) addRow();
else if (k === "Enter" || k === "F2") this.edit();
else if (k === "Delete" && ctrl) deleteRows();
else if (k === "Delete" || k === "Backspace") this.clear();
else if (ctrl && k.toLowerCase() === "a") {
this.anchor = { r: 0, c: 0 };
this.act = { r: this.view.length - 1, c: this.cols.length - 1 };
this.draw();
status();
} else if (ctrl && k.toLowerCase() === "z") this.doUndo();
else if (ctrl && k.toLowerCase() === "y") this.doRedo();
else if (ctrl && k.toLowerCase() === "s") save();
else if (ctrl && k.toLowerCase() === "d") duplicateRows();
else if (ctrl && k.toLowerCase() === "f") $("dt-search").focus();
else if (!ctrl && !e.altKey && k.length === 1) {
this.edit(k);
e.preventDefault();
return;
} else return;
e.preventDefault();
}
// ----- editing -----
edit(start) {
if (!this.view.length) return;
const vr = this.act.r;
const c = this.act.c;
const row = this.rows[this.view[vr]];
this.scrollTo(vr, c);
const col = this.cols[c];
const input = el("input", { class: "dt-editor", type: "text", spellcheck: "false" });
input.value = start ?? row.v[c];
input.style.left = `${this.colLeft(c)}px`;
input.style.top = `${vr * RH}px`;
input.style.width = `${Math.max(this.widths[c], col.text ? 260 : 120)}px`;
if (col.enums) input.setAttribute("list", this.enumList(col));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.commitEdit();
this.move(e.shiftKey ? -1 : 1, 0, false);
this.host.focus();
} else if (e.key === "Tab") {
e.preventDefault();
this.commitEdit();
this.move(0, e.shiftKey ? -1 : 1, false);
this.host.focus();
} else if (e.key === "Escape") {
e.preventDefault();
this.cancelEdit();
}
e.stopPropagation();
});
input.addEventListener("blur", () => this.commitEdit());
this.body.append(input);
this.editor = { input, row, c };
input.focus();
if (start === undefined) input.select();
}
enumList(col) {
const id = `dt-enum-${col.name}`;
if (!document.getElementById(id)) document.body.append(el("datalist", { id }, col.enums.map((e) => el("option", { value: String(e.value), label: e.name }))));
return id;
}
commitEdit() {
const ed = this.editor;
if (!ed) return;
this.editor = null;
let v = ed.input.value;
const col = this.cols[ed.c];
// an enum can be typed by its name
if (col.enums && !/^-?\d+$/.test(v.trim())) {
const e = col.enums.find((x) => x.name.toLowerCase() === v.trim().toLowerCase());
if (e) v = String(e.value);
}
if (!col.text) v = v.trim();
ed.input.remove();
if (v !== ed.row.v[ed.c]) this.change([{ row: ed.row, c: ed.c, v }]);
else this.draw();
}
cancelEdit() {
const ed = this.editor;
if (!ed) return;
this.editor = null;
ed.input.remove();
this.host.focus();
this.draw();
}
/** cell changes [{ row, c, v }] as one undo step */
change(list) {
const step = { cells: list.map((x) => ({ row: x.row, c: x.c, old: x.row.v[x.c], v: x.v })) };
for (const x of step.cells) x.row.v[x.c] = x.v;
this.undo.push(step);
this.redo = [];
this.draw();
status();
}
/** a row operation: the rows array before and after */
rowsStep(after) {
this.undo.push({ before: this.rows, after });
this.redo = [];
this.rows = after;
this.applyView();
this.layout();
status();
}
doUndo() {
const s = this.undo.pop();
if (!s) return;
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.old;
else {
this.rows = s.before;
this.applyView();
this.layout();
}
this.redo.push(s);
this.draw();
status();
}
doRedo() {
const s = this.redo.pop();
if (!s) return;
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.v;
else {
this.rows = s.after;
this.applyView();
this.layout();
}
this.undo.push(s);
this.draw();
status();
}
clear() {
const s = this.sel();
const list = [];
for (let vr = s.r0; vr <= s.r1; vr++) for (let c = s.c0; c <= s.c1; c++) list.push({ row: this.rows[this.view[vr]], c, v: this.cols[c].text ? "" : "0" });
this.change(list);
}
// ----- clipboard (tab-separated, like Excel) -----
copy(e) {
if (this.editor) return;
const s = this.sel();
const lines = [];
for (let vr = s.r0; vr <= s.r1; vr++) {
const row = this.rows[this.view[vr]];
lines.push(row.v.slice(s.c0, s.c1 + 1).map((v) => (/[\t\n"]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v)).join("\t"));
}
e.clipboardData.setData("text/plain", lines.join("\r\n"));
e.preventDefault();
toast(`Copied ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} cells.`);
}
paste(e) {
if (this.editor) return;
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
const grid = parseTsv(text);
if (!grid.length) return;
const s = this.sel();
// one value into a bigger selection fills it; otherwise the block starts at the top-left cell
const fill = grid.length === 1 && grid[0].length === 1 && (s.r1 > s.r0 || s.c1 > s.c0);
const list = [];
let after = null;
const need = fill ? 0 : s.r0 + grid.length - this.view.length;
if (need > 0) {
// more rows than there are below: new rows at the end
after = [...this.rows];
for (let i = 0; i < need; i++) after.push({ o: -1, v: this.cols.map((c) => (c.text ? "" : "0")) });
this.rows = after;
this.applyView();
}
const rowsR = fill ? s.r1 - s.r0 + 1 : grid.length;
const colsR = fill ? s.c1 - s.c0 + 1 : Math.min(grid[0].length, this.cols.length - s.c0);
for (let i = 0; i < rowsR; i++) {
const row = this.rows[this.view[s.r0 + i]];
if (!row) break;
for (let j = 0; j < colsR; j++) {
const c = s.c0 + j;
if (c >= this.cols.length) break;
const v = fill ? grid[0][0] : grid[i][j] ?? "";
list.push({ row, c, v: this.cols[c].text ? v : v.trim() });
}
}
if (after) {
// rows added and filled: one undo step for both
const before = this.rows.slice(0, this.rows.length - need);
for (const x of list) x.row.v[x.c] = x.v;
this.undo.push({ before, after: this.rows });
this.redo = [];
this.layout();
} else this.change(list);
this.anchor = { r: s.r0, c: s.c0 };
this.act = { r: s.r0 + rowsR - 1, c: s.c0 + colsR - 1 };
this.draw();
status();
toast(`Pasted ${rowsR} × ${colsR} cells${need > 0 ? ` (${need} new rows)` : ""}.`);
}
selectedRows() {
const s = this.sel();
return this.view.slice(s.r0, s.r1 + 1);
}
}
function parseTsv(text) {
const rows = [];
let row = [];
let cell = "";
let q = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++;
else if (ch === '"') q = false;
else cell += ch;
} else if (ch === '"' && cell === "") q = true;
else if (ch === "\t") row.push(cell), (cell = "");
else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = "");
else cell += ch;
}
if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row);
return rows;
}
// ---------- row commands ----------
function addRow() {
const S = T.cur;
if (!S) return;
const at = S.view.length ? S.view[S.sel().r1] + 1 : S.rows.length;
const blank = { o: -1, v: S.cols.map((c) => (c.text ? "" : "0")) };
// a new key: the first column one above the largest when it's a number
const key = S.cols[0];
if (key && !key.text) blank.v[0] = String(Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1);
const after = [...S.rows.slice(0, at), blank, ...S.rows.slice(at)];
S.rowsStep(after);
const vr = S.view.indexOf(at);
if (vr >= 0) {
S.act = { r: vr, c: 0 };
S.anchor = { ...S.act };
S.scrollTo(vr, 0);
S.draw();
}
}
function duplicateRows() {
const S = T.cur;
if (!S || !S.view.length) return;
const pick = S.selectedRows().sort((a, b) => a - b);
const at = pick[pick.length - 1] + 1;
let next = Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1;
const copies = pick.map((i) => {
const v = [...S.rows[i].v];
if (!S.cols[0].text) v[0] = String(next++);
return { o: -1, v };
});
S.rowsStep([...S.rows.slice(0, at), ...copies, ...S.rows.slice(at)]);
toast(`Duplicated ${copies.length} row(s)${!S.cols[0].text ? ` with new ${S.cols[0].name} values` : ""}.`);
}
async function deleteRows() {
const S = T.cur;
if (!S || !S.view.length) return;
const pick = new Set(S.selectedRows());
if (pick.size > 1 && !(await confirmBox("Delete rows", `Delete ${pick.size} rows?`, "Delete", "danger"))) return;
S.rowsStep(S.rows.filter((_, i) => !pick.has(i)));
}
// ---------- save / import / export ----------
async function save() {
const S = T.cur;
if (!S || T.busy || !S.dirty()) return;
S.commitEdit();
T.busy = true;
try {
const enums = Object.fromEntries(S.cols.filter((c) => c.enums).map((c) => [c.name, c.enums]));
const r = await call(
"dat:save",
S.id,
S.rows.map((x) => ({ o: x.o, v: x.v })),
{ enums },
);
S.info = r;
S.setData(r.rows);
S.undo = [];
S.redo = [];
S.applyView();
S.layout();
toast(`Saved ${S.name}: ${r.rows.length} rows${r.backup ? ` (backup: ${r.backup.split(/[\\/]/).pop()})` : ""}.`);
} catch (err) {
toast(err.message, true);
} finally {
T.busy = false;
status();
}
}
async function exportTable() {
const S = T.cur;
if (!S) return;
const file = await call("dialog:saveAs", `${S.name}.xlsx`, "xlsx");
if (!file) return;
try {
const rows = S.rows.map((r) => r.v);
const out = /\.csv$/i.test(file) ? await call("dat:exportCsv", S.id, rows, file) : await call("dat:exportXlsx", S.id, rows, file);
toast(`Exported ${rows.length} rows to ${out}`);
} catch (err) {
toast(err.message, true);
}
}
async function importTable() {
const S = T.cur;
if (!S) return;
const file = await call("dat:pickFile", "Import rows from .xlsx / .csv", "sheet");
if (!file) return;
let r;
try {
r = await call("dat:import", S.id, file);
} catch (err) {
return toast(err.message, true);
}
const notes = [r.missing.length ? `Columns not in the file (left empty / 0): ${r.missing.join(", ")}.` : null, r.extra.length ? `Columns in the file that the table doesn't have (ignored): ${r.extra.join(", ")}.` : null].filter(Boolean);
const ok = await confirmBox("Import", [`Replace the ${S.rows.length} rows of ${S.name} with the ${r.rows.length} rows of ${file.split(/[\\/]/).pop()}? You can undo it, and nothing is written until you Save.`, ...notes].join("\n\n"), "Replace");
if (!ok) return;
// rows that match an existing key keep their original (so unchanged cells stay byte-identical)
const byKey = new Map(S.rows.map((x) => [x.v[0], x.o]));
S.rowsStep(r.rows.map((v) => ({ o: byKey.has(v[0]) ? byKey.get(v[0]) : -1, v: v.map((x, i) => (S.cols[i].text ? x : x.trim() || "0")) })));
}
// ---------- status ----------
function status() {
const S = T.cur;
$("dt-save").disabled = !S || !S.dirty() || T.busy;
$("dt-undo").disabled = !S?.undo.length;
$("dt-redo").disabled = !S?.redo.length;
for (const id of ["dt-add", "dt-dup", "dt-del", "dt-import", "dt-export", "dt-enc", "dt-search"]) $(id).disabled = !S;
if (!S) return ($("dt-status").textContent = "");
const s = S.sel();
const changed = S.rows.filter((r) => r.o < 0 || r.v.some((v, c) => v !== S.orig[r.o]?.[c])).length;
const col = S.cols[S.act.c];
const row = S.rows[S.view[S.act.r]];
$("dt-status").textContent = [
`${S.name} · ${S.info.label}`,
`${S.rows.length} rows${S.view.length !== S.rows.length ? ` (${S.view.length} shown)` : ""} × ${S.cols.length} columns · ${S.info.rowSize} bytes/row · header ${S.info.headerVersion}`,
changed || S.rows.length !== S.orig.length ? `${changed} changed row(s)${S.rows.length !== S.orig.length ? `, ${S.rows.length - S.orig.length >= 0 ? "+" : ""}${S.rows.length - S.orig.length} rows` : ""}` : "no changes",
col && row ? `${col.name} (${col.typeName})${col.meta?.desc ? ` — ${col.meta.desc}` : ""}${s.r1 > s.r0 || s.c1 > s.c0 ? ` · ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} selected` : ""}` : "",
S.meta?.source ? `source: ${S.meta.source}` : "",
]
.filter(Boolean)
.join(" · ");
}
// ---------- wiring ----------
$("dt-add").addEventListener("click", addRow);
$("dt-dup").addEventListener("click", duplicateRows);
$("dt-del").addEventListener("click", deleteRows);
$("dt-undo").addEventListener("click", () => T.cur?.doUndo());
$("dt-redo").addEventListener("click", () => T.cur?.doRedo());
$("dt-save").addEventListener("click", save);
$("dt-export").addEventListener("click", exportTable);
$("dt-import").addEventListener("click", importTable);
$("dt-search").addEventListener("input", () => {
if (!T.cur) return;
T.cur.filter = $("dt-search").value.trim();
T.cur.applyView();
T.cur.layout();
status();
});
$("dt-enc").addEventListener("change", async () => {
const S = T.cur;
if (!S) return;
if (S.dirty() && !(await confirmBox("Code page", "Reading the texts in another code page drops the unsaved changes.", "Switch", "danger"))) {
$("dt-enc").value = S.info.encoding;
return;
}
const d = await call("dat:encoding", S.id, $("dt-enc").value);
S.info = { ...S.info, ...d };
S.setData(d.rows);
S.undo = [];
S.redo = [];
S.applyView();
S.layout();
status();
});
$("dt-client").addEventListener("click", async () => {
const dir = await call("dialog:folder", "Game folder (with the .iop packs)");
if (!dir) return;
T.src = { kind: "packs", folder: dir, name: dir };
listTables();
});
$("dt-server").addEventListener("click", async () => {
const dir = await call("dialog:folder", "The game server's tables folder (ls_gamesvr\\config\\tables)");
if (!dir) return;
LS("dt:server", dir);
listTables();
});
$("dt-file").addEventListener("click", async () => {
const file = await call("dat:pickFile", "Open a .dat table", "dat");
if (file) openTable({ kind: "file", file }, `file|${file}`);
});
$("dt-maker").addEventListener("click", async () => {
const dir = await call("dialog:folder", "ioDataMaker folder (with Config.xlsx and ExcelData)");
if (!dir) return;
LS("dt:maker", dir);
toast("Column descriptions and enum names come from there now (reopen the table).");
});
$("dt-reload").addEventListener("click", listTables);
window.addEventListener("beforeunload", () => T.cur && call("dat:close", T.cur.id));
document.querySelector('#tabs [data-view="tables"]').addEventListener("click", () => {
if (!T.listed) {
T.listed = true;
listTables();
status();
}
});
window.addEventListener("profile-changed", () => {
T.listed = false;
T.src = null;
if ($("view-tables").classList.contains("on")) {
T.listed = true;
listTables();
}
});
})();