diff --git a/src/app/App.tsx b/src/app/App.tsx index e66623ee..cfbd32be 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,9 +3,9 @@ import { Router } from 'preact-router' import '../styles/global.css' import '../styles/nodes.css' import { Analytics } from './Analytics.js' -import { cleanUrl } from './Utils.js' import { Header } from './components/index.js' -import { Changelog, Customized, Generator, Generators, Guide, Guides, Home, LegacyPartners, Partners, Sounds, Transformation, Versions, WhatsNew, Worldgen } from './pages/index.js' +import { Changelog, Convert, Customized, Generator, Generators, Guide, Guides, Home, LegacyPartners, Partners, Sounds, Transformation, Versions, WhatsNew, Worldgen } from './pages/index.js' +import { cleanUrl } from './Utils.js' export function App() { const changeRoute = (e: RouterOnChangeArgs) => { @@ -27,6 +27,8 @@ export function App() { + + diff --git a/src/app/components/Icons.tsx b/src/app/components/Icons.tsx index 1459ee45..f98dcfd4 100644 --- a/src/app/components/Icons.tsx +++ b/src/app/components/Icons.tsx @@ -6,6 +6,8 @@ export const Icons = { report: , sounds: , customized: , + convert: + , advancement: , banner_pattern: , block_definition: , diff --git a/src/app/pages/Convert.tsx b/src/app/pages/Convert.tsx new file mode 100644 index 00000000..a7221fb3 --- /dev/null +++ b/src/app/pages/Convert.tsx @@ -0,0 +1,327 @@ +import { Identifier, ItemStack, Json, NbtCompound, NbtString, NbtTag, StringReader } from 'deepslate' +import { route } from 'preact-router' +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' +import config from '../../config.json' +import { Footer, Octicon } from '../components/index.js' +import { useLocale } from '../contexts/Locale.jsx' +import { useTitle } from '../contexts/Title.jsx' +import { useActiveTimeout } from '../hooks/useActiveTimout.js' +import { useLocalStorage } from '../hooks/useLocalStorage.js' +import type { VersionId } from '../services/Versions.js' +import { checkVersion } from '../services/Versions.js' +import { jsonToNbt } from '../Utils.js' + +const FORMATS = ['give-command', 'loot-table'] as const +type Format = typeof FORMATS[number] + +interface Props { + path?: string, + formats?: string, +} +export function Convert({ formats }: Props) { + const {locale} = useLocale() + + const [source, setSource] = useState() + const [target, setTarget] = useState() + + useEffect(() => { + const match = formats?.match(/^([a-z0-9-]+)-to-([a-z0-9-]+)/) + if (match && FORMATS.includes(match[1] as Format)) { + setSource(match[1] as Format) + } + if (match && FORMATS.includes(match[2] as Format)) { + setTarget(match[2] as Format) + } + }, [formats]) + + const supportedVersions = useMemo(() => { + return config.versions + .filter(v => checkVersion(v.id, '1.20.5')) + .map(v => v.id as VersionId) + .reverse() + }, []) + + const title = !source || !target + ? locale('title.convert') + : locale('title.convert.formats', locale(`convert.format.${source}`), locale(`convert.format.${target}`)) + useTitle(title, supportedVersions) + + const [input, setInput] = useLocalStorage('misode_convert_input', '') + + const convertFn = useMemo(() => { + if (!source || !target) { + return undefined + } + if (source === target) { + return (input: string) => input + } + return CONVERSIONS[source][target] + }, [source, target]) + + const { output, error } = useMemo(() => { + if (!convertFn) { + return { output: '' } + } + try { + return { output: convertFn(input) } + } catch (e) { + return { output: '', error: e instanceof Error ? e : undefined } + } + }, [convertFn, input]) + + const changeSource = useCallback((newSource: Format) => { + setSource(newSource) + if (target === newSource) { + setTarget(source) + setInput(output) + } + if (target) { + route(`/convert/${newSource}-to-${target === newSource ? source : target}`) + } + }, [source, target]) + + const changeTarget = useCallback((newTarget: Format) => { + setTarget(newTarget) + if (source === newTarget) { + setSource(target) + setInput(output) + } + if (source) { + route(`/convert/${source === newTarget ? target : source}-to-${newTarget}`) + } + }, [source]) + + const onSwap = useCallback(() => { + setSource(target) + setTarget(source) + if (output.length > 0) { + setInput(output) + } + if (source && target) { + route(`/convert/${target}-to-${source}`) + } + }, [source, target, output]) + + const [copyActive, setCopyActive] = useActiveTimeout() + const onCopyOutput = useCallback(async () => { + await navigator.clipboard.writeText(output) + setCopyActive() + }, [output]) + + return
+
+
+ + + +
+
+
+ + {error &&
{error.message}
} +
+
+ + +
+
+
+
+
+} + +interface FormatSelectProps { + value: string | undefined + onChange: (newValue: Format) => void +} +function FormatSelect({ value, onChange }: FormatSelectProps) { + const { locale } = useLocale() + return +} + +const CONVERSIONS: Record string>>> = { + 'give-command': { + 'loot-table': (input) => { + const itemStack = parseGiveCommand(new StringReader(input)) + const lootTable = createLootTable(itemStack) + return JSON.stringify(lootTable, null, 2) + }, + }, + 'loot-table': { + 'give-command': (input) => { + const lootTable = JSON.parse(input) + const itemStack = getItemFromLootTable(lootTable) + return `give @s ${stringifyItemStack(itemStack)}` + }, + }, +} + +function parseGiveCommand(reader: StringReader) { + if (reader.peek() === '/') { + reader.skip() + } + if (reader.peek() === 'g' && reader.peek(1) === 'i' && reader.peek(2) === 'v' && reader.peek(3) === 'e') { + reader.cursor += 4 + reader.expect(' ') + reader.expect('@') + if (reader.peek().match(/[parsen]/)) { + reader.skip() + } else { + throw reader.createError("Expected 'p', 'a', 'r', 's', 'e', or 'n'") + } + reader.expect(' ') + } + const item = parseIdentifier(reader) + const components = parseComponents(reader) + let count = 1 + if (reader.peek() === ' ') { + reader.skip() + count = reader.readInt() + } + return new ItemStack(item, count, components) +} + + +function parseComponents(reader: StringReader) { + const components = new Map() + if (reader.peek() !== '[') { + return components + } + reader.skip() + reader.skipWhitespace() + while (reader.peek() !== ']') { + if (reader.peek() === '!') { + reader.skip() + reader.skipWhitespace() + const key = parseIdentifier(reader) + components.set('!' + key, new NbtCompound()) + reader.skipWhitespace() + } else { + const key = parseIdentifier(reader) + reader.skipWhitespace() + reader.expect('=') + reader.skipWhitespace() + const tag = NbtTag.fromString(reader) + reader.skipWhitespace() + if (key.is('custom_data')) { + components.set(key.toString(), new NbtString(tag.toString())) + } else { + components.set(key.toString(), tag) + } + } + if (reader.peek() === ']') { + break + } else if (reader.peek() === ',') { + reader.skip() + reader.skipWhitespace() + continue + } + throw reader.createError("Expected ',' or ']'") + } + reader.skip() + return components +} + +function parseIdentifier(reader: StringReader) { + const start = reader.cursor + while (reader.canRead() && reader.peek().match(/[a-z0-9_.:\/-]/)) { + reader.skip() + } + const result = reader.getRead(start) + if (result.length === 0) { + throw reader.createError('Expected a resource location') + } + return Identifier.parse(result) +} + +function createLootTable(item: ItemStack) { + return { + pools: [ + { + rolls: 1, + entries: [ + { + type: 'minecraft:item', + name: item.id.toString(), + functions: (item.components.size > 0 || item.count > 1) + ? [ + ...item.components.size > 0 ? [{ + function: 'minecraft:set_components', + components: Object.fromEntries([...item.components.entries()].map(([key, value]) => { + return [key, value.toSimplifiedJson()] + })), + }] : [], + ...item.count > 1 ? [{ + function: 'minecraft:set_count', + count: item.count, + }]: [], + ] + : undefined, + }, + ], + }, + ], + } +} + +function getItemFromLootTable(data: unknown): ItemStack { + const root = Json.readObject(data) ?? {} + const pools = Json.readArray(root.pools, e => Json.readObject(e) ?? {}) ?? [] + if (pools.length === 0) { + throw new Error('Expected a pool') + } + const pool = pools[0] + const entries = Json.readArray(pool.entries, e => Json.readObject(e) ?? {}) ?? [] + if (entries.length === 0) { + throw new Error('Expected an entry') + } + const entry = entries[0] + const type = Json.readString(entry.type) + if (type?.replace(/^minecraft:/, '') !== 'item') { + throw new Error('Expected "type" to be "minecraft:item"') + } + const name = Json.readString(entry.name) + if (!name) { + throw new Error('Expected "name"') + } + const functions = [ + ...Json.readArray(entry.functions, e => Json.readObject(e) ?? {}) ?? [], + ...Json.readArray(pool.functions, e => Json.readObject(e) ?? {}) ?? [], + ...Json.readArray(root.functions, e => Json.readObject(e) ?? {}) ?? [], + ] + let count = 1 + const components = new Map() + for (const fn of functions) { + const type = Json.readString(fn.function)?.replace(/^minecraft:/, '') + switch (type) { + case 'set_count': + const value = Json.readInt(fn.count) + if (value) { + count = value + } + break + case 'set_components': + const newComponents = Json.readObject(fn.components) ?? {} + for (const [key, value] of Object.entries(newComponents)) { + components.set(key, jsonToNbt(value)) + } + } + } + return new ItemStack(Identifier.parse(name), count, components) +} + +function stringifyItemStack(itemStack: ItemStack) { + let result = itemStack.id.toString() + if (itemStack.components.size > 0) { + result += `[${[...itemStack.components.entries()].map(([k, v]) => { + return k.startsWith('!') ? k : `${k}=${v.toString()}` + }).join(',')}]` + } + if (itemStack.count > 1) { + result += ` ${itemStack.count}` + } + return result +} diff --git a/src/app/pages/Home.tsx b/src/app/pages/Home.tsx index cb85726b..00fc42b2 100644 --- a/src/app/pages/Home.tsx +++ b/src/app/pages/Home.tsx @@ -87,6 +87,9 @@ function Tools() { const { locale } = useLocale() return + @@ -99,9 +102,6 @@ function Tools() { - diff --git a/src/app/pages/index.ts b/src/app/pages/index.ts index f18191f5..443ead1b 100644 --- a/src/app/pages/index.ts +++ b/src/app/pages/index.ts @@ -1,4 +1,5 @@ export * from './Changelog.js' +export * from './Convert.jsx' export * from './Customized.jsx' export * from './Generator.js' export * from './Generators.jsx' diff --git a/src/locales/en.json b/src/locales/en.json index 973b09db..ced1d927 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -23,6 +23,10 @@ "contributor.report": "Bug reporter", "contributor.support": "Supporter", "contributor.translation": "Translator", + "convert.format.give-command": "/give", + "convert.format.loot-table": "Loot Table", + "convert.select": "-- select --", + "convert.swap": "Swap", "copied": "Copied!", "copy": "Copy", "copy_context": "Copy context", @@ -265,6 +269,8 @@ "theme.light": "Light", "theme.system": "System", "title.changelog": "Technical Changelog", + "title.convert": "Converter", + "title.convert.formats": "%0% to %1% Converter", "title.customized": "Customized Worlds", "title.generator": "%0% Generator", "title.generator_category": "%0% Generators", diff --git a/src/styles/global.css b/src/styles/global.css index 5c78c79f..51a08214 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -978,6 +978,22 @@ main.has-project { min-width: unset;; } +.convert-select { + background-color: var(--background-2); + color: var(--text-1); +} + +.convert-textarea { + background-color: var(--background-2); + color: var(--text-1); + height: calc(100vh - 250px); + min-height: 250px; +} + +.convert-error { + color: var(--invalid-text); +} + .btn { display: flex; align-items: center;