diff --git a/BUILD.md b/BUILD.md index 3e01cd2..f07dc91 100644 --- a/BUILD.md +++ b/BUILD.md @@ -278,3 +278,59 @@ Visual Studio 2017 поддерживает работу с cmake без пре } ] ``` + +## WebAssembly (браузерный просмотр таблиц) + +### Что нужно для сборки + +1. Активированный Emscripten (`em++` в `PATH`) +2. Boost, собранный под target Emscripten (нужны `filesystem`, `system`, `regex`) + +git clone https://github.com/emscripten-core/emsdk.git +cd emsdk +./emsdk install latest +./emsdk activate latest +source ./emsdk_env.sh + +curl -L -o boost1.88.tar.gz +tar xf boost1.88.tar.gz +cd boost1.88 + +./bootstrap.sh +cat > user-config.jam <<'EOF' +using clang : emscripten : em++ ; +EOF + +export BOOST_WASM_ROOT="$HOME/opt/boost-wasm" +mkdir -p "$BOOST_WASM_ROOT" + +./b2 -j"$(nproc)" \ + toolset=clang-emscripten \ + target-os=emscripten \ + variant=release \ + link=static runtime-link=static \ + threading=single \ + --with-system --with-filesystem --with-regex \ + cxxflags="-O3" \ + install --prefix="$BOOST_WASM_ROOT" + +### Сборка WASM + +```sh +BOOST_WASM_ROOT=/path/to/boost-wasm ./web/build-wasm.sh +``` +После успешной сборки появятся: + +- `web/parser.js` +- `web/parser.wasm` + +### Локальный запуск + +```sh +cd web +python3 -m http.server 8080 +``` + +Открыть в браузере: + +`http://localhost:8080` diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..db9b6c5 --- /dev/null +++ b/web/app.js @@ -0,0 +1,381 @@ +const statusEl = document.getElementById("status"); +const errorEl = document.getElementById("error"); +const fileInput = document.getElementById("fileInput"); +const openBtn = document.getElementById("openBtn"); +const tablesEl = document.getElementById("tables"); +const sortFieldEl = document.getElementById("sortField"); +const sortDirEl = document.getElementById("sortDir"); +const tableMetaEl = document.getElementById("tableMeta"); +const tableContentHeadEl = document.getElementById("tableContentHead"); +const tableContentBodyEl = document.getElementById("tableContentBody"); + +let worker = null; +let requestSeq = 1; +const pendingRequests = new Map(); +let currentTables = []; +let selectedTableName = ""; +const TABLE_PREVIEW_LIMIT = 100; + +function setStatus(message) { + statusEl.textContent = message; +} + +function showError(message) { + errorEl.style.display = "block"; + errorEl.textContent = message; +} + +function clearError() { + errorEl.style.display = "none"; + errorEl.textContent = ""; +} + +function clearTables() { + tablesEl.replaceChildren(); +} + +function clearTableContent() { + tableContentHeadEl.replaceChildren(); + tableContentBodyEl.replaceChildren(); + tableMetaEl.textContent = "Select a table to preview first 100 rows."; +} + +function formatSize(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return "0 B"; + + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; +} + +function extractName(value, depth = 0) { + if (depth > 10 || value == null) return ""; + if (typeof value === "string") return value; + + if (Array.isArray(value)) { + for (const item of value) { + const candidate = extractName(item, depth + 1); + if (candidate) return candidate; + } + return ""; + } + + if (typeof value === "object") { + const preferredKeys = [ + "name", + "Name", + "tableName", + "table_name", + "table", + "title", + "value", + ]; + for (const key of preferredKeys) { + if (key in value) { + const candidate = extractName(value[key], depth + 1); + if (candidate) return candidate; + } + } + + for (const nestedValue of Object.values(value)) { + const candidate = extractName(nestedValue, depth + 1); + if (candidate) return candidate; + } + } + + return ""; +} + +function extractSize(value, depth = 0) { + if (depth > 3 || value == null) return 0; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + if (Array.isArray(value)) { + for (const item of value) { + const candidate = extractSize(item, depth + 1); + if (candidate > 0) return candidate; + } + return 0; + } + + if (typeof value === "object") { + const preferredKeys = ["size", "totalSize", "bytes", "value"]; + for (const key of preferredKeys) { + if (key in value) { + const candidate = extractSize(value[key], depth + 1); + if (candidate > 0) return candidate; + } + } + } + + return 0; +} + +function normalizeTables(parsed) { + if (!Array.isArray(parsed)) { + if (parsed && Array.isArray(parsed.tables)) { + return normalizeTables(parsed.tables); + } + throw new Error("Table JSON format is unsupported. Expected array or {tables:[...]}"); + } + + return parsed.map((item) => { + if (typeof item === "string") { + return { name: item, size: 0, raw: item }; + } + + const rawName = item?.name ?? item; + const rawSize = item?.size ?? item; + const normalizedName = extractName(rawName) || extractName(item) || "(unknown)"; + return { + name: normalizedName === "[object Object]" ? "(unknown)" : normalizedName, + size: extractSize(rawSize), + raw: item, + }; + }); +} + +function sortedTables() { + const sortField = sortFieldEl.value; + const isAsc = sortDirEl.value === "asc"; + const sign = isAsc ? 1 : -1; + const rows = [...currentTables]; + + rows.sort((a, b) => { + if (sortField === "size") { + if (a.size !== b.size) { + return (a.size - b.size) * sign; + } + return a.name.localeCompare(b.name) * sign; + } + + const nameCmp = a.name.localeCompare(b.name); + if (nameCmp !== 0) { + return nameCmp * sign; + } + return (a.size - b.size) * sign; + }); + + return rows; +} + +function renderTables() { + clearTables(); + + for (const table of sortedTables()) { + const tr = document.createElement("tr"); + tr.classList.toggle("active", table.name === selectedTableName); + + const nameTd = document.createElement("td"); + const fallbackName = + extractName(table.raw) || + (table.raw && typeof table.raw === "object" ? JSON.stringify(table.raw) : ""); + const displayName = + typeof table.name === "string" && table.name !== "[object Object]" + ? table.name + : (fallbackName || "(unknown)"); + nameTd.textContent = displayName; + + const sizeTd = document.createElement("td"); + sizeTd.textContent = formatSize(table.size); + sizeTd.className = "size"; + + tr.appendChild(nameTd); + tr.appendChild(sizeTd); + tr.addEventListener("click", () => { + void loadTableContent(table.name); + }); + tablesEl.appendChild(tr); + } +} + +function renderTableContent(payload) { + tableContentHeadEl.replaceChildren(); + tableContentBodyEl.replaceChildren(); + + const fields = Array.isArray(payload?.fields) ? payload.fields : []; + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + + const headerRow = document.createElement("tr"); + const rowNumTh = document.createElement("th"); + rowNumTh.className = "rownum"; + rowNumTh.textContent = "#"; + headerRow.appendChild(rowNumTh); + + for (const field of fields) { + const th = document.createElement("th"); + th.textContent = String(field?.name ?? ""); + headerRow.appendChild(th); + } + tableContentHeadEl.appendChild(headerRow); + + for (const row of rows) { + const tr = document.createElement("tr"); + if (row?.deleted) { + tr.classList.add("deleted"); + } + + const rowNumTd = document.createElement("td"); + rowNumTd.className = "rownum"; + rowNumTd.textContent = String(row?.row ?? ""); + tr.appendChild(rowNumTd); + + const values = Array.isArray(row?.values) ? row.values : []; + for (const value of values) { + const td = document.createElement("td"); + td.textContent = String(value ?? ""); + tr.appendChild(td); + } + + tableContentBodyEl.appendChild(tr); + } + + const offset = Number(payload?.offset) || 0; + const totalRows = Number(payload?.totalRows) || 0; + const shownTo = Math.min(offset + rows.length, totalRows); + tableMetaEl.textContent = + `${payload?.table ?? ""}: showing ${offset}-${shownTo} of ${totalRows} rows`; +} + +function initWorker() { + worker = new Worker("./wasm_worker.js"); + + worker.addEventListener("message", (event) => { + const message = event.data || {}; + const request = pendingRequests.get(message.id); + if (!request) { + return; + } + pendingRequests.delete(message.id); + if (message.ok) { + request.resolve(message.result); + } else { + request.reject(new Error(String(message.error || "Unknown worker error"))); + } + }); + + worker.addEventListener("error", (event) => { + const message = event.message || "WASM worker crashed"; + for (const request of pendingRequests.values()) { + request.reject(new Error(message)); + } + pendingRequests.clear(); + }); +} + +function workerCall(method, params = {}) { + if (!worker) { + return Promise.reject(new Error("WASM worker is not initialized")); + } + + return new Promise((resolve, reject) => { + const id = requestSeq++; + pendingRequests.set(id, { resolve, reject }); + worker.postMessage({ id, method, params }); + }); +} + +async function loadTableContent(tableName) { + clearError(); + setStatus(`Loading rows from ${tableName}...`); + try { + const payload = await workerCall("getTableRows", { + tableName, + offset: 0, + limit: TABLE_PREVIEW_LIMIT, + }); + selectedTableName = tableName; + renderTables(); + renderTableContent(payload); + setStatus(`Loaded ${tableName} preview`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + showError(message); + setStatus("Failed to load table content."); + } +} + +async function onOpenClick() { + clearError(); + clearTables(); + clearTableContent(); + + const file = fileInput.files?.[0]; + if (!file) { + showError("Please choose a .1CD file first."); + return; + } + + openBtn.disabled = true; + try { + setStatus("Mounting selected file in WORKERFS..."); + await workerCall("openFile", { file }); + + setStatus("Loading table list..."); + const parsed = await workerCall("listTables"); + currentTables = normalizeTables(parsed); + selectedTableName = ""; + renderTables(); + clearTableContent(); + setStatus(`Loaded ${currentTables.length} tables from ${file.name}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + showError(message); + setStatus("Failed to open file."); + } finally { + openBtn.disabled = false; + } +} + +async function main() { + clearError(); + clearTables(); + clearTableContent(); + openBtn.disabled = true; + + try { + initWorker(); + await workerCall("init"); + setStatus("WASM worker runtime is ready."); + openBtn.disabled = false; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + showError(`WASM init error: ${message}`); + setStatus("Runtime initialization failed."); + } +} + +fileInput.addEventListener("change", () => { + clearError(); +}); + +openBtn.addEventListener("click", () => { + void onOpenClick(); +}); + +sortFieldEl.addEventListener("change", () => { + renderTables(); +}); + +sortDirEl.addEventListener("change", () => { + renderTables(); +}); + +window.addEventListener("beforeunload", () => { + if (worker) { + worker.terminate(); + } +}); + +void main(); diff --git a/web/build-wasm.sh b/web/build-wasm.sh new file mode 100755 index 0000000..7be79b6 --- /dev/null +++ b/web/build-wasm.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build tool1cd WASM API for browser usage. +# Output: web/parser.js + web/parser.wasm +# +# Prereqs: +# 1) Emscripten activated (em++ available) +# 2) Boost built for Emscripten (filesystem/system/regex) +# +# Example: +#BOOST_WASM_ROOT=$HOME/opt/boost-wasm ./web/build-wasm.sh + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="$ROOT_DIR/web" +SRC_DIR="$ROOT_DIR/src/tool1cd" + +BOOST_WASM_ROOT="${BOOST_WASM_ROOT:-}" +if [[ -z "${BOOST_WASM_ROOT}" ]]; then + echo "ERROR: Set BOOST_WASM_ROOT to your Boost-for-WASM prefix." >&2 + exit 1 +fi + +if ! command -v em++ >/dev/null 2>&1; then + echo "ERROR: em++ not found. Activate emsdk first." >&2 + exit 1 +fi + +TOOL1CD_SOURCES=( + main.cpp MessageRegistration.cpp Class_1CD.cpp + Common.cpp ConfigStorage.cpp Parse_tree.cpp TempStream.cpp Base64.cpp UZLib.cpp Messenger.cpp + V8Object.cpp Field.cpp Index.cpp Table.cpp TableFiles.cpp TableFileStream.cpp + MemBlock.cpp CRC32.cpp Packdata.cpp PackDirectory.cpp FieldType.cpp DetailedException.cpp + BinaryDecimalNumber.cpp save_depot_config.cpp save_part_depot_config.cpp + SupplierConfig.cpp TableRecord.cpp BinaryGuid.cpp TableIterator.cpp SupplierConfigBuilder.cpp + cfapi/V8File.cpp cfapi/V8Catalog.cpp cfapi/TV8FileStream.cpp cfapi/APIcfBase.cpp cfapi/V8Time.cpp + SystemClasses/String.cpp SystemClasses/System.Classes.cpp SystemClasses/System.cpp + SystemClasses/System.IOUtils.cpp SystemClasses/TFileStream.cpp SystemClasses/TMemoryStream.cpp + SystemClasses/TStream.cpp SystemClasses/TStreamReader.cpp SystemClasses/TStreamWriter.cpp + SystemClasses/System.SysUtils.cpp SystemClasses/GetTickCount.cpp +) + +ABS_SOURCES=() +for src in "${TOOL1CD_SOURCES[@]}"; do + ABS_SOURCES+=("$SRC_DIR/$src") +done +ABS_SOURCES+=("$OUT_DIR/wasm_api.cpp") + +mkdir -p "$OUT_DIR" + +em++ \ + "${ABS_SOURCES[@]}" \ + -std=c++11 -O2 \ + -I"$SRC_DIR" \ + -I"$BOOST_WASM_ROOT/include" \ + -L"$BOOST_WASM_ROOT/lib" \ + -lboost_filesystem -lboost_system -lboost_regex \ + -sUSE_ZLIB=1 \ + -lworkerfs.js \ + -sFORCE_FILESYSTEM=1 \ + -sALLOW_MEMORY_GROWTH=1 \ + -sENVIRONMENT=web,worker \ + -sMODULARIZE=0 \ + -sEXPORT_ES6=0 \ + -sNO_EXIT_RUNTIME=1 \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_onecd_open','_onecd_list_tables_json','_onecd_get_table_rows_json','_onecd_last_error','_onecd_close','_onecd_free_string']" \ + -sEXPORTED_RUNTIME_METHODS="['FS','cwrap','UTF8ToString']" \ + -o "$OUT_DIR/parser.js" + +echo "Built: $OUT_DIR/parser.js and $OUT_DIR/parser.wasm" diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 0000000..2e06bbb Binary files /dev/null and b/web/favicon.ico differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b4ed28e --- /dev/null +++ b/web/index.html @@ -0,0 +1,262 @@ + + + + + + 1CD Tables Viewer + + + +
+
+

1CD Tables Viewer

+ +
+ + +
+ +

Loading WASM runtime...

+ + +

Tables

+
+ + + + + +
+ + + + + + + + + +
NameSize
+

Click a table name to view rows.

+ +

Table Content

+

Select a table to preview first 100 rows.

+
+ + + +
+
+

Expected WASM exports: onecd_open, onecd_list_tables_json, onecd_get_table_rows_json, onecd_free_string

+
+
+ + + + diff --git a/web/wasm_api.cpp b/web/wasm_api.cpp new file mode 100644 index 0000000..58193d1 --- /dev/null +++ b/web/wasm_api.cpp @@ -0,0 +1,305 @@ +/* +Tool1CD library provides access to 1CD database files. + Copyright © 2009-2017 awa + Copyright © 2017-2018 E8 Tools contributors + + This file is part of Tool1CD Library. + + Tool1CD Library is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Tool1CD Library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Tool1CD Library. If not, see . +*/ + +#include "Class_1CD.h" +#include "Field.h" +#include "Table.h" + +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#else +#define EMSCRIPTEN_KEEPALIVE +#endif + +namespace { + +std::unique_ptr g_db; +std::string g_last_error; + +char *dup_cstr(const std::string &s) { + char *out = static_cast(std::malloc(s.size() + 1)); + if (!out) { + out = static_cast(std::malloc(1)); + if (!out) return nullptr; // is it possible? + out[0] = '\0'; + return out; + } + + std::memcpy(out, s.c_str(), s.size() + 1); + return out; +} + +std::string json_escape(const std::string& s) { + std::string out; + out.reserve(s.size() + 16); + + for (char ch : s) { + switch (ch) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(ch) < 0x20) { + out += "\\u00"; + const char* hex = "0123456789abcdef"; + out += hex[(static_cast(ch) >> 4) & 0x0F]; + out += hex[static_cast(ch) & 0x0F]; + } else { + out += ch; + } + break; + } + } + + return out; +} + +void set_error(const std::string& message) { + g_last_error = message; +} + +uint64_t object_size(const V8Object* file) { + return file ? file->get_len() : 0; +} + +uint64_t table_total_size(const Table* table) { + if (!table) { + return 0; + } + + return object_size(table->get_file_data()) + + object_size(table->get_file_blob()) + + object_size(table->get_file_index()); +} + +Table* find_table_by_name(const std::string& table_name) { + if (!g_db || !g_db->is_open()) { + return nullptr; + } + + const int table_count = g_db->get_numtables(); + for (int i = 0; i < table_count; ++i) { + Table* table = g_db->get_table(i); + if (table && table->get_name() == table_name) { + return table; + } + } + + return nullptr; +} + +} // namespace + +extern "C" { +EMSCRIPTEN_KEEPALIVE int onecd_open(const char *path) { + if (!path || !*path) { + set_error("onecd_open: empty path"); + return -1; + } + + try { + std::unique_ptr db(new T_1CD()); + db->open(path, false); + g_db = std::move(db); + g_last_error.clear(); + return 0; + } catch (const std::exception &ex) { + set_error(ex.what()); + return -2; + } catch (...) { + set_error("onecd_open: unknown error"); + return -3; + } +} + +EMSCRIPTEN_KEEPALIVE const char* onecd_list_tables_json() { + if (!g_db || !g_db->is_open()) { + set_error("Database is not open"); + return dup_cstr("[]"); + } + + try { + const int table_count = g_db->get_numtables(); + std::string json; + json.reserve(static_cast(table_count) * 64 + 2); + json.push_back('['); + + for (int i = 0; i < table_count; ++i) { + if (i > 0) { + json.push_back(','); + } + + Table* table = g_db->get_table(i); + const std::string table_name = table ? table->get_name() : std::string(); + const uint64_t table_size = table_total_size(table); + + json += "{\"name\":\""; + json += json_escape(table_name); + json += "\",\"size\":"; + json += std::to_string(table_size); + json.push_back('}'); + } + + json.push_back(']'); + g_last_error.clear(); + return dup_cstr(json); + } catch (const std::exception& ex) { + set_error(ex.what()); + return dup_cstr("[]"); + } catch (...) { + set_error("onecd_list_tables_json: unknown error"); + return dup_cstr("[]"); + } +} + +EMSCRIPTEN_KEEPALIVE const char* onecd_get_table_rows_json(const char* table_name, int offset, int limit) { + if (!g_db || !g_db->is_open()) { + set_error("Database is not open"); + return dup_cstr("{\"error\":\"Database is not open\"}"); + } + + if (!table_name || !*table_name) { + set_error("onecd_get_table_rows_json: empty table name"); + return dup_cstr("{\"error\":\"empty table name\"}"); + } + + if (offset < 0) { + offset = 0; + } + if (limit <= 0) { + limit = 100; + } + if (limit > 200) { + limit = 200; + } + + try { + Table* table = find_table_by_name(table_name); + if (!table) { + set_error(std::string("Table not found: ") + table_name); + return dup_cstr("{\"error\":\"table not found\"}"); + } + + const uint32_t total_rows = table->get_phys_numrecords(); + const int field_count = table->get_num_fields(); + const int start = offset > static_cast(total_rows) ? static_cast(total_rows) : offset; + const int end = (start + limit) > static_cast(total_rows) + ? static_cast(total_rows) + : (start + limit); + + std::string json; + json.reserve(static_cast(field_count) * 64 + static_cast(end - start) * 256 + 256); + json += "{\"table\":\""; + json += json_escape(table->get_name()); + json += "\",\"offset\":"; + json += std::to_string(start); + json += ",\"limit\":"; + json += std::to_string(limit); + json += ",\"totalRows\":"; + json += std::to_string(total_rows); + json += ",\"fields\":["; + + for (int i = 0; i < field_count; ++i) { + if (i > 0) { + json.push_back(','); + } + Field* field = table->get_field(i); + json += "{\"name\":\""; + json += json_escape(field ? field->get_name() : std::string()); + json += "\"}"; + } + + json += "],\"rows\":["; + std::vector record_buf(static_cast(table->get_recordlen())); + + for (int row = start; row < end; ++row) { + if (row > start) { + json.push_back(','); + } + + table->get_record(static_cast(row), record_buf.data()); + + json += "{\"row\":"; + json += std::to_string(row); + json += ",\"deleted\":"; + json += (record_buf[0] != '\0') ? "true" : "false"; + json += ",\"values\":["; + + for (int col = 0; col < field_count; ++col) { + if (col > 0) { + json.push_back(','); + } + + Field* field = table->get_field(col); + std::string value; + try { + value = field ? field->get_presentation(record_buf.data()) : std::string(); + } catch (const std::exception& ex) { + value = std::string("{ERROR: ") + ex.what() + "}"; + } catch (...) { + value = "{ERROR}"; + } + + json.push_back('"'); + json += json_escape(value); + json.push_back('"'); + } + + json += "]}"; + } + + json += "]}"; + g_last_error.clear(); + return dup_cstr(json); + } catch (const std::exception& ex) { + set_error(ex.what()); + return dup_cstr("{\"error\":\"internal error\"}"); + } catch (...) { + set_error("onecd_get_table_rows_json: unknown error"); + return dup_cstr("{\"error\":\"unknown error\"}"); + } +} + +EMSCRIPTEN_KEEPALIVE const char* onecd_last_error() { + return dup_cstr(g_last_error); +} + +EMSCRIPTEN_KEEPALIVE void onecd_close() { + g_db.reset(); + g_last_error.clear(); +} + +EMSCRIPTEN_KEEPALIVE void onecd_free_string(const char* ptr) { + std::free(const_cast(ptr)); +} + +} // extern "C"