From 268c77f9ab0486858085b2788a1bd29aba87cf34 Mon Sep 17 00:00:00 2001 From: Ricky Merc <65817116+RickyB505@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:05:27 -0400 Subject: [PATCH 1/2] tweak(docs): highlight the FiveM config snippets as ini --- docs/src/content/docs/getting-started.md | 8 ++++---- docs/src/content/docs/key-bindings.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 23256899..fc640406 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -36,7 +36,7 @@ prints an error in your server console telling you what to rename it to. Open your `server.cfg` and add this: -```cfg +```ini add_filesystem_permission vMenu.Enhanced write vMenu.Enhanced ensure vMenu.Enhanced ``` @@ -85,7 +85,7 @@ Open `permissions.cfg` and set it up however you like. If you just want to get going quickly and only play with friends, all you need in there is this single line: -```cfg +```ini add_ace builtin.everyone "vMenu.Enhanced.Everything" allow ``` @@ -99,7 +99,7 @@ join. Go back to your `server.cfg` and update what you added earlier so it now looks like this: -```cfg +```ini exec @vMenu.Enhanced/config/permissions.cfg add_filesystem_permission vMenu.Enhanced write vMenu.Enhanced ensure vMenu.Enhanced @@ -116,7 +116,7 @@ settings rather than permissions, so things like how vMenu behaves and what is t It works exactly the same way. Make a copy, call it `configuration.cfg`, edit it how you like, and add one more `exec` line to your `server.cfg`: -```cfg +```ini exec @vMenu.Enhanced/config/permissions.cfg exec @vMenu.Enhanced/config/configuration.cfg add_filesystem_permission vMenu.Enhanced write vMenu.Enhanced diff --git a/docs/src/content/docs/key-bindings.md b/docs/src/content/docs/key-bindings.md index 722487ff..95157e9d 100644 --- a/docs/src/content/docs/key-bindings.md +++ b/docs/src/content/docs/key-bindings.md @@ -58,7 +58,7 @@ nothing for anybody who leaves it off. Put the convar in your `server.cfg` **before** the line that starts vMenu Enhanced: -```cfg +```ini setr vMenu.Enhanced.KeyBindings.TeleportKey "F7" setr vMenu.Enhanced.KeyBindings.NoClipToggleKey "F3" ensure vMenu.Enhanced From 837d60ce402189cdfc90be0c992d885be68b4e9a Mon Sep 17 00:00:00 2001 From: Ricky Merc <65817116+RickyB505@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:05:52 -0400 Subject: [PATCH 2/2] feat(docs): add a page to view and edit your transfer code --- docs/astro.config.mjs | 1 + docs/src/content/docs/moving-your-data.md | 8 + docs/src/pages/data-editor.astro | 83 +++++ docs/src/scripts/bundle.js | 238 ++++++++++++ docs/src/scripts/data-tool.js | 426 ++++++++++++++++++++++ docs/src/scripts/vmenu-code.js | 86 +++++ docs/src/scripts/zip.js | 109 ++++++ docs/src/styles/data-tool.css | 298 +++++++++++++++ 8 files changed, 1249 insertions(+) create mode 100644 docs/src/pages/data-editor.astro create mode 100644 docs/src/scripts/bundle.js create mode 100644 docs/src/scripts/data-tool.js create mode 100644 docs/src/scripts/vmenu-code.js create mode 100644 docs/src/scripts/zip.js create mode 100644 docs/src/styles/data-tool.css diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index cf96d1a0..ec998a23 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -62,6 +62,7 @@ export default defineConfig({ { label: 'Getting Started', link: '/getting-started/' }, { label: 'Key Bindings', link: '/key-bindings/' }, { label: 'Moving Your Data', link: '/moving-your-data/' }, + { label: 'Edit Your Data', link: '/data-editor/' }, { label: 'Custom Character Creator', link: '/character-creator/' }, { label: 'Configuration', diff --git a/docs/src/content/docs/moving-your-data.md b/docs/src/content/docs/moving-your-data.md index fce56802..f026cd9e 100644 --- a/docs/src/content/docs/moving-your-data.md +++ b/docs/src/content/docs/moving-your-data.md @@ -54,6 +54,14 @@ starting over from a known good copy. Either way, paste the code into the box with `Ctrl+V` and press `Enter`. vMenu tells you how many things it brought back, and everything takes effect straight away. You do not need to reconnect. +## Looking inside a code + +A code is not meant to be read by a human, but you do not have to take its word for what is in it. +The [Edit Your Data](/vmenu/enhanced/data-editor/) page unpacks one in your browser and shows you every saved +vehicle, ped, character, loadout and setting it holds. You can rename things there, change them, +throw out the ones you no longer want, and download a fresh code together with a backup of the one +you started with. + ## What is and is not in the code In it: diff --git a/docs/src/pages/data-editor.astro b/docs/src/pages/data-editor.astro new file mode 100644 index 00000000..63787653 --- /dev/null +++ b/docs/src/pages/data-editor.astro @@ -0,0 +1,83 @@ +--- +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; + +import '../styles/data-tool.css'; + +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); +--- + + +

+ A transfer code is everything vMenu has saved on your computer, squashed into one long line of + text. Paste one in below to see what is in it, change it, and get a new code back. If you do not + have one yet, Moving Your Data explains how to get one + out of the game. +

+ +
+
+ + + + +
+ + + + +
+
+ + +
+ +

What the buttons do

+ +

+ Every item opens into its name and its contents, written out as JSON. Break the JSON and the page + says so, and holds the download until you have fixed it. +

+ + + +

Worth knowing

+ + +
+ + diff --git a/docs/src/scripts/bundle.js b/docs/src/scripts/bundle.js new file mode 100644 index 00000000..94507b88 --- /dev/null +++ b/docs/src/scripts/bundle.js @@ -0,0 +1,238 @@ +export const FORMAT = "vmenu.enhanced.kvp"; +export const OWNED_PREFIX = "vmenu_"; +export const OTHER_GROUP = "Everything else"; + +export const GROUPS = [ + { label: "Saved vehicles", prefix: "vmenu_vehicle_" }, + { label: "Vehicle categories", prefix: "vmenu_vehcategory_" }, + { label: "Saved peds", prefix: "vmenu_ped_" }, + { label: "Ped categories", prefix: "vmenu_pedcategory_" }, + { label: "Custom characters", prefix: "vmenu_mpchar_" }, + { label: "Character categories", prefix: "vmenu_mpcharcategory_" }, + { label: "Weapon loadouts", prefix: "vmenu_weaponloadout_" }, + { label: "Settings", prefix: "vmenu_default_" }, +]; + +/* Longest first, so a key never lands in a group whose prefix is merely the start of another. */ +const MATCHERS = [...GROUPS].sort((left, right) => right.prefix.length - left.prefix.length); + +export function prop(object, name) { + const wanted = name.toLowerCase(); + + return Object.keys(object).find(key => key.toLowerCase() === wanted); +} + +export function read(object, name) { + const found = prop(object, name); + + return found === undefined ? undefined : object[found]; +} + +export function pretty(value) { + return JSON.stringify(value === undefined ? null : value, null, 2); +} + +export function readable(plain) { + try { + return JSON.stringify(JSON.parse(plain), null, 2); + } catch { + return plain; + } +} + +export function matcherFor(key) { + return MATCHERS.find(group => key.startsWith(group.prefix)); +} + +export function nameOf(item) { + const group = matcherFor(item.key); + + return group ? item.key.slice(group.prefix.length) : item.key; +} + +export function readBundle(plain) { + let bundle = null; + + try { + bundle = JSON.parse(plain); + } catch { + return { error: "json" }; + } + + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + return { error: "json" }; + } + + if (read(bundle, "format") !== FORMAT) { + return { error: "format" }; + } + + const entriesProp = prop(bundle, "entries"); + + if (!entriesProp || !Array.isArray(bundle[entriesProp])) { + return { error: "entries" }; + } + + return { bundle, entriesProp, items: bundle[entriesProp].map(toItem).filter(Boolean) }; +} + +function toItem(entry, index) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null; + } + + const item = { + id: index, + entry, + entryKeyProp: prop(entry, "key") || "key", + entryRawProp: prop(entry, "raw") || "raw", + key: String(read(entry, "key") || ""), + raw: String(read(entry, "raw") || ""), + envelope: null, + envKeyProp: "key", + envValueProp: "value", + type: "", + version: 0, + text: "", + error: null, + removed: false, + open: false, + broken: false, + }; + + let envelope = null; + + try { + envelope = JSON.parse(item.raw); + } catch { + envelope = null; + } + + if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) { + item.broken = true; + + return item; + } + + item.envelope = envelope; + item.envKeyProp = prop(envelope, "key") || "key"; + item.envValueProp = prop(envelope, "value") || "value"; + item.type = String(read(envelope, "type") || ""); + item.version = Number(read(envelope, "version") || 0); + item.text = pretty(envelope[item.envValueProp]); + + return item; +} + +export function bucket(items) { + const buckets = new Map(GROUPS.map(group => [group.label, []])); + + buckets.set(OTHER_GROUP, []); + + for (const item of items) { + const group = matcherFor(item.key); + + buckets.get(group ? group.label : OTHER_GROUP).push(item); + } + + for (const [label, held] of buckets) { + if (held.length === 0) { + buckets.delete(label); + } + } + + return buckets; +} + +export function validate(item) { + try { + JSON.parse(item.text); + item.error = null; + } catch (problem) { + item.error = "That is not valid JSON. " + (problem.message || problem); + } + + return item.error; +} + +export function renameItem(items, item, wanted) { + const clean = String(wanted || "").trim(); + + if (clean.length === 0) { + return { error: "A name cannot be empty." }; + } + + const group = matcherFor(item.key); + const key = group ? group.prefix + clean : clean; + + if (!key.startsWith(OWNED_PREFIX)) { + return { error: "vMenu only accepts keys starting with " + OWNED_PREFIX + ", so this one would be skipped." }; + } + + if (items.some(other => other !== item && !other.removed && other.key === key)) { + return { error: "Something else in this code already has that name." }; + } + + const was = nameOf(item); + + item.key = key; + + if (item.envelope) { + item.envelope[item.envKeyProp] = key; + } + + renameInside(item, was, clean); + + return { name: clean }; +} + +/* The name lives in the payload as well as in the key, and vMenu shows that one in its menus. */ +function renameInside(item, was, now) { + try { + const value = JSON.parse(item.text); + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return; + } + + const nameProp = prop(value, "name"); + + if (nameProp === undefined || value[nameProp] !== was) { + return; + } + + value[nameProp] = now; + item.text = pretty(value); + } catch { + /* Mid edit the payload may not parse, and a stale name is better than losing the edit. */ + } +} + +export function buildBundle(source, entriesProp, items) { + const bundle = { ...source }; + const createdProp = prop(source, "createdAt") || "createdAt"; + + bundle[createdProp] = new Date().toISOString(); + bundle[entriesProp] = items.filter(item => !item.removed).map(toEntry); + + return bundle; +} + +function toEntry(item) { + const entry = { ...item.entry }; + + entry[item.entryKeyProp] = item.key; + entry[item.entryRawProp] = item.broken ? item.raw : rawOf(item); + + return entry; +} + +/* Rebuilt from the parsed envelope, so whatever a newer vMenu put in it survives the edit. */ +function rawOf(item) { + const envelope = { ...item.envelope }; + + envelope[item.envKeyProp] = item.key; + envelope[item.envValueProp] = JSON.parse(item.text); + + return JSON.stringify(envelope); +} diff --git a/docs/src/scripts/data-tool.js b/docs/src/scripts/data-tool.js new file mode 100644 index 00000000..c6628913 --- /dev/null +++ b/docs/src/scripts/data-tool.js @@ -0,0 +1,426 @@ +import { decodeCode, encodeCode } from "./vmenu-code.js"; +import { makeZip } from "./zip.js"; +import { + bucket, + buildBundle, + nameOf, + pretty, + read, + readBundle, + readable, + renameItem, + validate, +} from "./bundle.js"; + +const REASONS = { + empty: "There is nothing in the box yet. Paste your code first.", + prefix: "That does not look like a vMenu code. A real one starts with VME1.", + base64: "That code is damaged or incomplete. Copy the whole thing again, from VME1 all the way to the end.", + gzip: "That code could not be unpacked. It is usually incomplete, so copy the whole thing again.", + json: "That code unpacked, but what came out of it was not readable.", + format: "That unpacked fine, but it is not a vMenu data code.", + entries: "That code does not have a list of saved items in it.", +}; + +const HELP = [ + "What is in this zip", + "", + "new-code.txt", + " Your edited code. Copy the whole line, open vMenu in game, go to", + " Misc Settings, Import & Export, pick Paste A Code, and paste it in.", + "", + "new-code-readable.json", + " The same thing unpacked, so you can read it. vMenu does not want this one,", + " it only wants the code above.", + "", + "backup/original-code.txt", + " The code exactly as you pasted it into the page, before any edits.", + " If an edit went wrong, paste this one back into vMenu instead.", + "", + "backup/original-code-readable.json", + " The original, unpacked so you can read it.", +].join("\n"); + +const state = { + originalCode: "", + originalPlain: "", + bundle: null, + entriesProp: "entries", + items: [], +}; + +let codeEl = null; +let loadEl = null; +let pickEl = null; +let fileEl = null; +let statusEl = null; +let resultEl = null; +let summaryEl = null; +let groupsEl = null; +let downloadEl = null; +let copyEl = null; +let outEl = null; + +export function setup() { + codeEl = document.getElementById("vmt-code"); + + if (!codeEl) { + return; + } + + loadEl = document.getElementById("vmt-load"); + pickEl = document.getElementById("vmt-pick"); + fileEl = document.getElementById("vmt-file"); + statusEl = document.getElementById("vmt-status"); + resultEl = document.getElementById("vmt-result"); + summaryEl = document.getElementById("vmt-summary"); + groupsEl = document.getElementById("vmt-groups"); + downloadEl = document.getElementById("vmt-download"); + copyEl = document.getElementById("vmt-copy"); + outEl = document.getElementById("vmt-out"); + + loadEl.addEventListener("click", load); + pickEl.addEventListener("click", () => fileEl.click()); + fileEl.addEventListener("change", readFile); + downloadEl.addEventListener("click", download); + copyEl.addEventListener("click", copy); +} + +function el(tag, className, content) { + const node = document.createElement(tag); + + if (className) { + node.className = className; + } + + if (content !== undefined) { + node.textContent = content; + } + + return node; +} + +function say(target, message, tone) { + target.textContent = message || ""; + target.className = tone ? "vmt-status vmt-" + tone : "vmt-status"; +} + +async function readFile(event) { + const file = event.target.files && event.target.files[0]; + + if (!file) { + return; + } + + codeEl.value = await file.text(); + fileEl.value = ""; + + load(); +} + +async function load() { + say(statusEl, "Reading your code.", null); + + const decoded = await decodeCode(codeEl.value); + + if (decoded.error) { + fail(REASONS[decoded.error] || REASONS.json); + + return; + } + + const opened = readBundle(decoded.plain); + + if (opened.error) { + fail(REASONS[opened.error] || REASONS.json); + + return; + } + + state.originalCode = decoded.cleaned; + state.originalPlain = decoded.plain; + state.bundle = opened.bundle; + state.entriesProp = opened.entriesProp; + state.items = opened.items; + + resultEl.hidden = false; + + say(statusEl, "Read " + state.items.length + " saved item(s).", "good"); + render(); +} + +function fail(message) { + resultEl.hidden = true; + state.items = []; + + say(statusEl, message, "bad"); +} + +function render() { + const buckets = bucket(state.items); + + renderSummary(buckets); + + groupsEl.replaceChildren(); + + for (const [label, items] of buckets) { + groupsEl.appendChild(renderGroup(label, items)); + } + + refresh(); +} + +function renderSummary(buckets) { + summaryEl.replaceChildren(); + + const made = String(read(state.bundle, "createdAt") || ""); + const kept = state.items.filter(item => !item.removed).length; + + summaryEl.appendChild(el("div", "vmt-made", made ? "This code was made on " + made + "." : "This code is not dated.")); + + const chips = el("div", "vmt-chips"); + + for (const [label, items] of buckets) { + chips.appendChild(el("span", "vmt-chip", label + ": " + items.length)); + } + + chips.appendChild(el("span", "vmt-chip vmt-chip-total", "Kept in the new code: " + kept)); + + summaryEl.appendChild(chips); +} + +function renderGroup(label, items) { + const box = el("details", "vmt-group"); + + box.open = true; + + const head = el("summary", "vmt-group-head"); + + head.append(el("span", "vmt-group-name", label), el("span", "vmt-count", String(items.length))); + box.append(head); + + for (const item of items) { + box.append(renderItem(item)); + } + + return box; +} + +function renderItem(item) { + const box = el("details", item.removed ? "vmt-item vmt-gone" : "vmt-item"); + + box.open = item.open; + box.addEventListener("toggle", () => { + item.open = box.open; + }); + + const head = el("summary", "vmt-item-head"); + + head.append(el("span", "vmt-item-name", nameOf(item))); + + if (item.type) { + head.append(el("span", "vmt-meta", item.type + ", version " + item.version)); + } + + if (item.broken) { + head.append(el("span", "vmt-badge vmt-badge-bad", "unreadable")); + } + + if (item.removed) { + head.append(el("span", "vmt-badge vmt-badge-gone", "deleted")); + } + + box.append(head, item.broken ? renderBroken(item) : renderEditor(item)); + + return box; +} + +function renderBroken(item) { + const body = el("div", "vmt-item-body"); + + body.append(el( + "p", + "vmt-note", + "vMenu cannot read this one and would skip it anyway. It is passed through untouched.")); + + body.append(renderButtons(item, null)); + + return body; +} + +function renderEditor(item) { + const body = el("div", "vmt-item-body"); + + const nameRow = el("div", "vmt-row"); + const nameInput = el("input", "vmt-input"); + + nameInput.type = "text"; + nameInput.value = nameOf(item); + nameInput.spellcheck = false; + + const renameButton = el("button", "vmt-button", "Rename"); + + renameButton.type = "button"; + renameButton.addEventListener("click", () => rename(item, nameInput.value)); + + nameInput.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + rename(item, nameInput.value); + } + }); + + nameRow.append(el("label", "vmt-row-label", "Name"), nameInput, renameButton); + + const editor = el("textarea", "vmt-editor"); + + editor.value = item.text; + editor.spellcheck = false; + editor.rows = Math.min(24, Math.max(4, item.text.split("\n").length)); + + const problem = el("div", "vmt-problem"); + + editor.addEventListener("input", () => { + item.text = editor.value; + + problem.textContent = validate(item) || ""; + editor.classList.toggle("vmt-editor-bad", Boolean(item.error)); + + refresh(); + }); + + body.append(nameRow, editor, problem, renderButtons(item, editor)); + + return body; +} + +function renderButtons(item, editor) { + const row = el("div", "vmt-row vmt-row-end"); + + if (editor) { + const tidy = el("button", "vmt-button", "Reformat JSON"); + + tidy.type = "button"; + tidy.title = "Lays the JSON out again with even indentation. Nothing in it is changed."; + tidy.addEventListener("click", () => { + try { + item.text = pretty(JSON.parse(editor.value)); + item.error = null; + + render(); + } catch { + say(outEl, "That cannot be tidied up until the JSON is valid again.", "bad"); + } + }); + + row.append(tidy); + } + + const toggle = el( + "button", + item.removed ? "vmt-button" : "vmt-button vmt-danger", + item.removed ? "Put it back" : "Delete item"); + + toggle.type = "button"; + toggle.title = item.removed + ? "Puts this back into the new code." + : "Removes the item from the new code."; + toggle.addEventListener("click", () => { + item.removed = !item.removed; + + render(); + }); + + row.append(toggle); + + return row; +} + +function rename(item, wanted) { + const result = renameItem(state.items, item, wanted); + + if (result.error) { + say(outEl, result.error, "bad"); + + return; + } + + render(); + say(outEl, "Renamed to " + result.name + ".", "good"); +} + +function problems() { + return state.items.filter(item => !item.removed && item.error); +} + +function refresh() { + const broken = problems(); + const kept = state.items.filter(item => !item.removed).length; + + downloadEl.disabled = broken.length > 0 || kept === 0; + copyEl.disabled = downloadEl.disabled; + + if (broken.length > 0) { + say(outEl, "Fix the JSON on " + nameOf(broken[0]) + " before you download.", "bad"); + } else if (kept === 0) { + say(outEl, "Everything has been taken out, so there is nothing left to save.", "bad"); + } +} + +async function newCode() { + const bundle = buildBundle(state.bundle, state.entriesProp, state.items); + + return { bundle, code: await encodeCode(JSON.stringify(bundle)) }; +} + +async function download() { + if (problems().length > 0) { + return; + } + + say(outEl, "Packing your zip.", null); + + const made = await newCode(); + + const blob = makeZip([ + { name: "new-code.txt", text: made.code }, + { name: "new-code-readable.json", text: JSON.stringify(made.bundle, null, 2) }, + { name: "backup/original-code.txt", text: state.originalCode }, + { name: "backup/original-code-readable.json", text: readable(state.originalPlain) }, + { name: "read-me-first.txt", text: HELP }, + ]); + + const link = el("a"); + const url = URL.createObjectURL(blob); + + link.href = url; + link.download = "vmenu-data-" + new Date().toISOString().slice(0, 10) + ".zip"; + + document.body.append(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(url); + + say(outEl, "Downloaded. The zip holds your new code and a backup of the one you pasted in.", "good"); +} + +async function copy() { + if (problems().length > 0) { + return; + } + + const made = await newCode(); + + try { + await navigator.clipboard.writeText(made.code); + + say(outEl, "Copied. Paste it into vMenu under Misc Settings, Import & Export.", "good"); + } catch { + codeEl.value = made.code; + codeEl.focus(); + codeEl.select(); + + say(outEl, "Copying was blocked, so the new code is in the box at the top instead.", "bad"); + } +} diff --git a/docs/src/scripts/vmenu-code.js b/docs/src/scripts/vmenu-code.js new file mode 100644 index 00000000..f4efdc03 --- /dev/null +++ b/docs/src/scripts/vmenu-code.js @@ -0,0 +1,86 @@ +const GZIP_PREFIX = "VME1G:"; +const PLAIN_PREFIX = "VME1P:"; + +export const CODE_PREFIXES = [GZIP_PREFIX, PLAIN_PREFIX]; + +function toBase64(bytes) { + let binary = ""; + + /* In slices, because spreading a whole profile into fromCharCode blows the stack. */ + for (let at = 0; at < bytes.length; at += 0x8000) { + binary += String.fromCharCode.apply(null, bytes.subarray(at, at + 0x8000)); + } + + return btoa(binary); +} + +function fromBase64(code) { + const binary = atob(code); + const bytes = new Uint8Array(binary.length); + + for (let at = 0; at < binary.length; at++) { + bytes[at] = binary.charCodeAt(at); + } + + return bytes; +} + +async function through(bytes, transform) { + const stream = new Blob([bytes]).stream().pipeThrough(transform); + + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +export async function encodeCode(plain) { + const bytes = new TextEncoder().encode(plain); + + if (typeof CompressionStream === "function") { + try { + return GZIP_PREFIX + toBase64(await through(bytes, new CompressionStream("gzip"))); + } catch { + /* Older browsers without the stream still get a code, just a much longer one. */ + } + } + + return PLAIN_PREFIX + toBase64(bytes); +} + +export async function decodeCode(code) { + /* Stripped first: a code copied out of a chat message comes back wrapped in line breaks. */ + const cleaned = String(code || "").replace(/\s+/g, ""); + + if (cleaned.length === 0) { + return { error: "empty" }; + } + + const gzipped = cleaned.startsWith(GZIP_PREFIX); + + if (!gzipped && !cleaned.startsWith(PLAIN_PREFIX)) { + return { error: "prefix" }; + } + + let bytes; + + try { + /* Both prefixes are the same length, so one slice covers either. */ + bytes = fromBase64(cleaned.slice(GZIP_PREFIX.length)); + } catch { + return { error: "base64" }; + } + + if (gzipped) { + if (typeof DecompressionStream !== "function") { + return { error: "gzip" }; + } + + try { + bytes = await through(bytes, new DecompressionStream("gzip")); + } catch { + return { error: "gzip" }; + } + } + + const plain = new TextDecoder().decode(bytes); + + return plain.length === 0 ? { error: "empty" } : { plain, cleaned }; +} diff --git a/docs/src/scripts/zip.js b/docs/src/scripts/zip.js new file mode 100644 index 00000000..80fa114a --- /dev/null +++ b/docs/src/scripts/zip.js @@ -0,0 +1,109 @@ +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + + for (let index = 0; index < 256; index++) { + let value = index; + + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + + table[index] = value >>> 0; + } + + return table; +})(); + +function crc32(bytes) { + let crc = 0xffffffff; + + for (let at = 0; at < bytes.length; at++) { + crc = CRC_TABLE[(crc ^ bytes[at]) & 0xff] ^ (crc >>> 8); + } + + return (crc ^ 0xffffffff) >>> 0; +} + +function dosTime(date) { + return (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1); +} + +function dosDate(date) { + return ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); +} + +export function makeZip(files) { + const encoder = new TextEncoder(); + const now = new Date(); + const time = dosTime(now); + const date = dosDate(now); + + const bodies = []; + const directory = []; + let offset = 0; + + for (const file of files) { + const name = encoder.encode(file.name); + const body = encoder.encode(file.text); + const crc = crc32(body); + + const local = new Uint8Array(30 + name.length); + const localView = new DataView(local.buffer); + + localView.setUint32(0, 0x04034b50, true); + localView.setUint16(4, 20, true); + localView.setUint16(6, 0x0800, true); + localView.setUint16(8, 0, true); + localView.setUint16(10, time, true); + localView.setUint16(12, date, true); + localView.setUint32(14, crc, true); + localView.setUint32(18, body.length, true); + localView.setUint32(22, body.length, true); + localView.setUint16(26, name.length, true); + localView.setUint16(28, 0, true); + local.set(name, 30); + + const record = new Uint8Array(46 + name.length); + const recordView = new DataView(record.buffer); + + recordView.setUint32(0, 0x02014b50, true); + recordView.setUint16(4, 20, true); + recordView.setUint16(6, 20, true); + recordView.setUint16(8, 0x0800, true); + recordView.setUint16(10, 0, true); + recordView.setUint16(12, time, true); + recordView.setUint16(14, date, true); + recordView.setUint32(16, crc, true); + recordView.setUint32(20, body.length, true); + recordView.setUint32(24, body.length, true); + recordView.setUint16(28, name.length, true); + recordView.setUint16(30, 0, true); + recordView.setUint16(32, 0, true); + recordView.setUint16(34, 0, true); + recordView.setUint16(36, 0, true); + recordView.setUint32(38, 0, true); + recordView.setUint32(42, offset, true); + record.set(name, 46); + + bodies.push(local, body); + directory.push(record); + + offset += local.length + body.length; + } + + const directorySize = directory.reduce((total, record) => total + record.length, 0); + + const end = new Uint8Array(22); + const endView = new DataView(end.buffer); + + endView.setUint32(0, 0x06054b50, true); + endView.setUint16(4, 0, true); + endView.setUint16(6, 0, true); + endView.setUint16(8, directory.length, true); + endView.setUint16(10, directory.length, true); + endView.setUint32(12, directorySize, true); + endView.setUint32(16, offset, true); + endView.setUint16(20, 0, true); + + return new Blob([...bodies, ...directory, end], { type: "application/zip" }); +} diff --git a/docs/src/styles/data-tool.css b/docs/src/styles/data-tool.css new file mode 100644 index 00000000..32f12363 --- /dev/null +++ b/docs/src/styles/data-tool.css @@ -0,0 +1,298 @@ +.vmt { + display: flex; + flex-direction: column; + gap: 1.25rem; + margin-top: 1.5rem; +} + +.vmt .vmt-result:not([hidden]) { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.vmt .vmt-panel { + padding: 1rem; + border: var(--toon-border); + border-radius: var(--toon-radius); + background: var(--paper); + box-shadow: var(--toon-shadow-sm); +} + +.vmt .vmt-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.vmt .vmt-code, +.vmt .vmt-editor { + width: 100%; + padding: 0.6rem 0.7rem; + border: 2px solid var(--ink); + border-radius: 12px; + background: var(--cream); + color: var(--ink); + font-family: var(--sl-font-mono); + font-size: 0.8rem; + line-height: 1.45; + resize: vertical; +} + +.vmt .vmt-code { + min-height: 7rem; + word-break: break-all; +} + +.vmt .vmt-editor-bad { + border-color: var(--rose-deep); + background: #fff1f1; +} + +.vmt .vmt-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + margin-top: 0.75rem; +} + +.vmt .vmt-button { + padding: 0.35rem 0.9rem; + border: 2px solid var(--ink); + border-radius: 999px; + background: var(--paper); + box-shadow: 2px 2px 0 var(--ink); + color: var(--ink); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s var(--toon-pop), box-shadow 0.08s var(--toon-pop); +} + +.vmt .vmt-button:hover:not(:disabled) { + transform: translate(-1px, -1px); + box-shadow: 3px 3px 0 var(--ink); +} + +.vmt .vmt-button:active:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 0 0 0 var(--ink); +} + +.vmt .vmt-button:disabled { + opacity: 0.5; + box-shadow: none; + cursor: not-allowed; +} + +.vmt .vmt-primary { + background: var(--violet-deep); + color: var(--cream); +} + +.vmt .vmt-danger { + background: var(--rose-deep); + color: var(--cream); +} + +.vmt .vmt-file { + display: none; +} + +.vmt .vmt-status { + font-size: 0.85rem; + font-weight: 500; +} + +.vmt .vmt-good { + color: var(--mint-deep); +} + +.vmt .vmt-bad { + color: var(--rose-deep); +} + +.vmt .vmt-made { + font-weight: 600; +} + +.vmt .vmt-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.6rem; +} + +.vmt .vmt-chip { + padding: 0.1rem 0.6rem; + border: 2px solid var(--ink); + border-radius: 999px; + background: var(--violet-tint); + font-size: 0.8rem; +} + +.vmt .vmt-chip-total { + background: var(--mint-tint); +} + +.vmt .vmt-groups { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.vmt .vmt-group { + overflow: hidden; + border: var(--toon-border); + border-radius: var(--toon-radius); + background: var(--paper); + box-shadow: var(--toon-shadow-sm); +} + +.vmt .vmt-group-head, +.vmt .vmt-item-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + cursor: pointer; + list-style: none; +} + +.vmt .vmt-group-head::-webkit-details-marker, +.vmt .vmt-item-head::-webkit-details-marker { + display: none; +} + +.vmt .vmt-group-head::before, +.vmt .vmt-item-head::before { + content: ''; + flex: none; + width: 0.45rem; + height: 0.45rem; + border-right: 2px solid var(--ink); + border-bottom: 2px solid var(--ink); + transform: rotate(-45deg); + transition: transform 0.15s var(--toon-pop); +} + +.vmt details[open] > .vmt-group-head::before, +.vmt details[open] > .vmt-item-head::before { + transform: rotate(45deg); +} + +.vmt .vmt-group-head { + padding: 0.7rem 1rem; + background: var(--cream-deep); + font-weight: 600; +} + +.vmt .vmt-group-name { + margin-right: auto; +} + +.vmt .vmt-count { + padding: 0 0.5rem; + border: 2px solid var(--ink); + border-radius: 999px; + background: var(--paper); + font-size: 0.8rem; +} + +.vmt .vmt-item { + border-top: 2px solid var(--ink); +} + +.vmt .vmt-item-head { + padding: 0.5rem 1rem; +} + +.vmt .vmt-item-head:hover { + background: var(--violet-tint); +} + +.vmt .vmt-item-name { + font-weight: 600; + word-break: break-word; +} + +.vmt .vmt-gone .vmt-item-name { + text-decoration: line-through; +} + +.vmt .vmt-meta { + color: var(--sl-color-gray-3); + font-size: 0.78rem; +} + +.vmt .vmt-badge { + padding: 0 0.5rem; + border: 2px solid var(--ink); + border-radius: 999px; + background: var(--gold); + font-size: 0.72rem; + font-weight: 600; +} + +.vmt .vmt-badge-bad { + background: var(--rose); +} + +.vmt .vmt-badge-gone { + background: var(--rose-deep); + color: var(--cream); +} + +.vmt .vmt-item-body { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0 1rem 1rem; +} + +.vmt .vmt-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.vmt .vmt-row-end { + justify-content: flex-end; +} + +.vmt .vmt-row-label { + font-size: 0.85rem; + font-weight: 600; +} + +.vmt .vmt-input { + flex: 1 1 12rem; + padding: 0.3rem 0.6rem; + border: 2px solid var(--ink); + border-radius: 10px; + background: var(--cream); + color: var(--ink); + font: inherit; +} + +.vmt .vmt-problem { + color: var(--rose-deep); + font-size: 0.82rem; +} + +.vmt .vmt-note { + margin: 0; + font-size: 0.9rem; +} + +.vmt .vmt-sticky { + position: sticky; + bottom: 0.75rem; + background: var(--cream-deep); +} + +.vmt .vmt-sticky .vmt-actions { + margin-top: 0; +}