Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions docs/src/content/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
```

Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/key-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/moving-your-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions docs/src/pages/data-editor.astro
Original file line number Diff line number Diff line change
@@ -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(/\/$/, '');
---

<StarlightPage
frontmatter={{
title: 'Edit Your Data',
description: 'Open a vMenu transfer code in your browser, look through everything it holds, change it, and get a new code back.',
tableOfContents: false,
}}
>
<p>
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, <a href={`${base}/moving-your-data/`}>Moving Your Data</a> explains how to get one
out of the game.
</p>

<div class="vmt not-content">
<div class="vmt-panel">
<label class="vmt-label" for="vmt-code">Your transfer code</label>

<textarea id="vmt-code" class="vmt-code" spellcheck="false" autocomplete="off" placeholder="VME1G:..."></textarea>

<div class="vmt-actions">
<button id="vmt-load" class="vmt-button vmt-primary" type="button">Open my code</button>
<button id="vmt-pick" class="vmt-button" type="button">Open a text file instead</button>
<input id="vmt-file" class="vmt-file" type="file" accept=".txt,.json,text/plain" />
<span id="vmt-status" class="vmt-status"></span>
</div>
</div>

<div id="vmt-result" class="vmt-result" hidden>
<div class="vmt-panel" id="vmt-summary"></div>

<div id="vmt-groups" class="vmt-groups"></div>

<div class="vmt-panel vmt-sticky">
<div class="vmt-actions">
<button id="vmt-download" class="vmt-button vmt-primary" type="button">Download zip</button>
<button id="vmt-copy" class="vmt-button" type="button">Copy the new code</button>
<span id="vmt-out" class="vmt-status"></span>
</div>
</div>
</div>
</div>

<h2>What the buttons do</h2>

<p>
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.
</p>

<ul>
<li><strong>Rename</strong> changes the name in both of the places vMenu keeps it.</li>
<li><strong>Reformat JSON</strong> re-indents the box without changing anything in it.</li>
<li>
<strong>Delete item</strong> leaves it out of the new code, and <strong>Put it back</strong> undoes
that. It only leaves the game itself if you import with <strong>Paste A Code And Replace Everything</strong>.
</li>
<li><strong>Download zip</strong> gives you the new code, a readable copy of it, and a backup of the one you pasted in.</li>
</ul>

<h2>Worth knowing</h2>

<ul>
<li>Nothing reaches the game until you paste the new code back in under <strong>Misc Settings, Import &amp; Export</strong>.</li>
<li>The type and version beside each name are how vMenu knows what it is looking at, so the page will not let you edit them.</li>
<li>An item marked <strong>unreadable</strong> is one vMenu would skip anyway, and it is passed through untouched.</li>
<li>If an edit goes wrong, the backup in the zip is the code you walked in with.</li>
</ul>
</StarlightPage>

<script>
import { setup } from '../scripts/data-tool.js';

setup();
</script>
238 changes: 238 additions & 0 deletions docs/src/scripts/bundle.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading