LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+768
View File
@@ -0,0 +1,768 @@
"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();
}
});
})();