From b9ef53e5088427865f10b5f3167cbe48c4f8a1aa Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:27:34 +0300 Subject: [PATCH 01/27] feature: rpa packer + color picker fixes (wip) --- app/routes/tools.py | 3 +- app/utils/lifespan/search_cache.py | 2 +- static/css/colors.css | 1 + static/css/tools.css | 18 ++- static/js/colors.js | 67 +++++++-- static/js/tools-core.js | 32 ++++- static/js/tools-worker.js | 54 +++++++- static/js/tools.js | 213 +++++++++++++++++++++++++---- static/tools/rpa.py | 74 ++++++++++ templates/tools.html | 2 +- templates/tools_unpack.html | 49 ++++++- tests/rpa_browser.cjs | 167 ++++++++++++++++++++++ tests/test_tools.py | 7 +- tests/test_tools_rpa.py | 83 +++++++++++ tests/tools_core.test.mjs | 17 +++ tests/tools_pyodide.test.mjs | 13 ++ tests/tools_ui.test.mjs | 134 +++++++++++++++++- 17 files changed, 885 insertions(+), 51 deletions(-) create mode 100644 static/tools/rpa.py create mode 100644 tests/rpa_browser.cjs create mode 100644 tests/test_tools_rpa.py diff --git a/app/routes/tools.py b/app/routes/tools.py index f6a9e5f..c34191a 100644 --- a/app/routes/tools.py +++ b/app/routes/tools.py @@ -15,7 +15,7 @@ 'engine.py': 'text/plain', 'recovery.py': 'text/plain', 'vendor.zip': 'application/zip', 'pyc_decompiler.py': 'text/plain', 'bytecode.zip': 'application/zip', 'syntax.zip': 'application/zip', 'renpy_lexer.py': 'text/plain', - 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain', + 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain', 'rpa.py': 'text/plain', **{f'pyodide/{RUNTIME_VERSION}/{name}': media for name, media in { 'pyodide.mjs': 'application/javascript', 'pyodide.asm.js': 'application/javascript', 'pyodide.asm.wasm': 'application/wasm', 'python_stdlib.zip': 'application/zip', @@ -55,6 +55,7 @@ async def unpack_page(request: Request): return templates.TemplateResponse(request, 'tools_unpack.html', { 'tools_config': { 'worker': asset_url('/static/js/tools-worker.js'), + 'packer': f'/static/tools/rpa.py?v={digest("rpa.py")}', 'engine': f'/static/tools/engine.py?v={digest("engine.py")}', 'recovery': f'/static/tools/recovery.py?v={digest("recovery.py")}', 'pyc': f'/static/tools/pyc_decompiler.py?v={digest("pyc_decompiler.py")}', diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py index 47ecbd5..adfa153 100644 --- a/app/utils/lifespan/search_cache.py +++ b/app/utils/lifespan/search_cache.py @@ -17,7 +17,7 @@ ('Специалисты', '/specialists'), ('Материалы', '/materials'), ('Инструменты', '/tools'), - ('Распаковщик RPA/RPYC/PYC RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'), + ('Упаковщик и распаковщик RPA / Декомпиляция RPYC/PYC / RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'), ('Палитра цветов / Цвет / HEX / RGB / Палитры', '/tools/colors'), ('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'), ('Площадки для публикации', '/news-resources'), diff --git a/static/css/colors.css b/static/css/colors.css index 48b8dfc..96ef6c6 100644 --- a/static/css/colors.css +++ b/static/css/colors.css @@ -72,6 +72,7 @@ .picker-shortcuts p { color: var(--text-soft); margin: 12px 0; } .picker-shortcuts .picker-section-head { margin-bottom: 20px; } .picker-eyedropper-overlay { position: fixed; inset: 0; z-index: 10000; background: rgb(0 0 0 / .6); cursor: crosshair; } +.picker-eyedropper-overlay-capture { background: transparent; } .picker-eyedropper-help { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: min(360px, calc(100% - 32px)); box-sizing: border-box; padding: 20px 24px; border-radius: var(--radius-block); background: var(--bg); color: var(--text); box-shadow: 0 8px 32px #0004; } .picker-eyedropper-help h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: var(--fs-body); } .picker-eyedropper-help p { display: flex; align-items: center; gap: 16px; margin: 8px 0; font-size: var(--fs-ui); } diff --git a/static/css/tools.css b/static/css/tools.css index db93d21..f44a933 100644 --- a/static/css/tools.css +++ b/static/css/tools.css @@ -1,6 +1,16 @@ .tools-page .page-main { max-width: 66rem; } .tools-heading h2, .tools-links h2, #site-tools-heading { font-family: var(--font-serif); font-size: var(--fs-subtitle); font-weight: 700; margin: 0 0 8px; } .tools-heading p { color: var(--text-soft); margin: 0 0 24px; } +.tools-mode { display: flex; flex-wrap: wrap; gap: 8px 24px; border: 0; padding: 0; margin: 0 0 20px; font-size: var(--fs-ui); } +.tools-mode label { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; } +.tools-mode label:has(:checked) { font-weight: 600; } +.tools-pack-settings { margin-block: 20px 24px; } +.tools-pack-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 12px; } +.tools-pack-fields label { display: grid; gap: 8px; font-size: var(--fs-ui); font-weight: 600; min-width: 0; } +.tools-pack-fields :is(input, select) { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--text-soft); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-weight: 400; caret-color: var(--accent); } +.tools-pack-fields input::placeholder { color: var(--text-soft); opacity: 1; } +.tools-pack-fields [aria-invalid="true"] { border-color: var(--danger); } +@media (max-width: 700px) { .tools-pack-fields { grid-template-columns: minmax(0, 1fr); } } .tools-drop { border: 1px dashed var(--border); padding: 30px 20px 22px; text-align: center; background: var(--bg-light); border-radius: var(--radius-sm); } .tools-drop.is-dragging { border-color: var(--accent); border-style: solid; background: var(--accent-muted); } .tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; } @@ -11,7 +21,7 @@ .tools-button-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); } .tools-button-primary:hover { background: var(--accent-hover); color: var(--bg); } .tools-button:disabled { background: var(--bg-light); color: var(--text-soft); border-color: var(--border); cursor: default; } -.tools-page :is(button, a, input, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.tools-page :is(button, a, input, select, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; } .tools-add-menu { position: fixed; z-index: 110; min-width: 210px; padding: 6px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); } .tools-add-menu button { display: block; width: 100%; min-height: 44px; padding: 10px 14px; background: none; border: 0; text-align: start; color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; } .tools-add-menu button:hover { background: var(--bg-light); } @@ -31,6 +41,11 @@ .tools-queue-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; } .tools-queue-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; min-width: 0; } .tools-queue-summary .tools-meta { margin: 0; } +.tools-queue-tools { margin-top: 16px; } +.tools-queue-tools > label { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; font-size: var(--fs-ui); font-weight: 600; } +.tools-browser-search.tools-queue-search { border-color: var(--text-soft); } +.tools-queue-navigation, .tools-queue-pages { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; } +.tools-queue-navigation { justify-content: space-between; margin-top: 12px; } .tools-clear { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; } .tools-clear:hover:enabled { color: var(--danger); background: var(--bg-light); } .tools-clear:disabled { opacity: 0.5; cursor: default; } @@ -41,6 +56,7 @@ .tools-file-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 52px; padding: 6px 4px; } .tools-file-list li + li { border-top: 1px solid var(--border); } .tools-file-name { overflow-wrap: anywhere; font-size: var(--fs-ui); } +.tools-pack-path { display: block; padding-block: 6px; color: var(--text); } .tools-file-size { font-size: var(--fs-ui); color: var(--text-soft); font-variant-numeric: tabular-nums; } .tools-file-state { display: block; color: var(--text-soft); font-size: var(--fs-ui); } .tools-file-state.is-error { color: var(--danger); } diff --git a/static/js/colors.js b/static/js/colors.js index 6639a95..db1cb99 100644 --- a/static/js/colors.js +++ b/static/js/colors.js @@ -11,7 +11,7 @@ let color = c.parseHex('#2f7524'), hsv = c.rgbToHsv(color), other = c.parseHex('#fff'); let role = 'text', surface = 'field', pointer = null, disposed = false, imageRequest = 0, imagePoint = {x: 0, y: 0}; const undos = [], redos = [], copyTimers = new Map(); - let eyedropperOverlayTimer, eyedropperController; + let eyedropperOverlayTimer, eyedropperController, screenCaptureStream, screenCaptureVideo, screenPickResolve; const same = (first, second) => JSON.stringify(first) === JSON.stringify(second); const readList = key => { try { @@ -437,7 +437,27 @@ on(document, 'pointerdown', event => { if (event.button === 2) cancelEyedropper(event); }); on(document, 'contextmenu', cancelEyedropper); on(document, 'keydown', event => { if (event.key === 'Escape') cancelEyedropper(event); }); - if (window.EyeDropper && window.isSecureContext) { + on(get('eyedropper-overlay'), 'pointerdown', event => { + if (!screenCaptureVideo || event.button !== 0 || get('eyedropper-overlay').hidden) return; + if (event.target.closest?.('.picker-eyedropper-help')) return; + event.preventDefault(); + try { + const sampleCanvas = document.createElement('canvas'); + const scale = Math.min(1, 4096 / screenCaptureVideo.videoWidth, 4096 / screenCaptureVideo.videoHeight, Math.sqrt(12000000 / (screenCaptureVideo.videoWidth * screenCaptureVideo.videoHeight))); + sampleCanvas.width = Math.max(1, Math.round(screenCaptureVideo.videoWidth * scale)); sampleCanvas.height = Math.max(1, Math.round(screenCaptureVideo.videoHeight * scale)); + const context = sampleCanvas.getContext('2d', {willReadFrequently: true}); + context.drawImage(screenCaptureVideo, 0, 0, sampleCanvas.width, sampleCanvas.height); + const x = c.clamp(Math.floor(event.clientX / window.innerWidth * screenCaptureVideo.videoWidth * scale), 0, sampleCanvas.width - 1); + const y = c.clamp(Math.floor(event.clientY / window.innerHeight * screenCaptureVideo.videoHeight * scale), 0, sampleCanvas.height - 1); + const pixel = context.getImageData(x, y, 1, 1).data; + screenPickResolve?.({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] / 255}); + } catch (_) { + get('status').textContent = 'Не удалось прочитать цвет экрана. Выберите вкладку Firefox в окне захвата.'; + } + }); + const nativeEyedropper = window.EyeDropper && window.isSecureContext; + const screenEyedropper = window.isSecureContext && window.navigator.mediaDevices?.getDisplayMedia; + if (nativeEyedropper || screenEyedropper) { get('eyedropper').hidden = false; on(get('eyedropper'), 'click', async () => { const pickController = new AbortController(); @@ -445,12 +465,40 @@ const abortPick = () => pickController.abort(); controller.signal.addEventListener('abort', abortPick, {once: true}); get('eyedropper').disabled = true; - // Chromium snapshots the screen on open. Delay the dimmer until after - // that capture so the magnifier samples the original page colors. - eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300); - try { const result = await new window.EyeDropper().open({signal: pickController.signal}); if (!disposed && !pickController.signal.aborted) choose(c.parseHex(result.sRGBHex)); } - catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; } - finally { controller.signal.removeEventListener('abort', abortPick); if (eyedropperController === pickController) eyedropperController = null; clearTimeout(eyedropperOverlayTimer); if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper').focus({preventScroll: true}); } } + try { + let result; + if (nativeEyedropper) { + // Chromium snapshots the screen on open. Delay the dimmer until after + // that capture so the magnifier samples the original page colors. + eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300); + result = c.parseHex((await new window.EyeDropper().open({signal: pickController.signal})).sRGBHex); + } else { + get('status').textContent = 'В окне Firefox выберите эту вкладку для захвата экрана.'; + screenCaptureStream = await window.navigator.mediaDevices.getDisplayMedia({video: true, audio: false, preferCurrentTab: true}); + screenCaptureVideo = document.createElement('video'); + screenCaptureVideo.muted = true; screenCaptureVideo.playsInline = true; screenCaptureVideo.srcObject = screenCaptureStream; + await screenCaptureVideo.play(); + if (!screenCaptureVideo.videoWidth || !screenCaptureVideo.videoHeight) throw new Error('Captured tab is not ready'); + if (pickController.signal.aborted || disposed) return; + get('eyedropper-overlay').classList.add('picker-eyedropper-overlay-capture'); + get('eyedropper-overlay').hidden = false; + get('status').textContent = 'Нажмите на нужный цвет. Esc отменяет выбор.'; + result = await new Promise((resolve, reject) => { + screenPickResolve = resolve; + pickController.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true}); + }); + } + if (!disposed && !pickController.signal.aborted) choose(result); + } + catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : error.name === 'NotAllowedError' ? 'Захват вкладки отменён. Разрешите его или выберите цвет из изображения.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; } + finally { + controller.signal.removeEventListener('abort', abortPick); + if (eyedropperController === pickController) eyedropperController = null; + screenPickResolve = null; screenCaptureVideo = null; + screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null; + clearTimeout(eyedropperOverlayTimer); + if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture'); get('eyedropper').focus({preventScroll: true}); } + } }); } const canvas = get('image-canvas'); @@ -521,7 +569,8 @@ window.__esdocColorsCleanup = () => { disposed = true; imageRequest++; controller.abort(); clearTimeout(eyedropperOverlayTimer); - shortcuts.close(); get('eyedropper-overlay').hidden = true; + screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null; screenCaptureVideo = null; screenPickResolve = null; + shortcuts.close(); get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture'); for (const timer of copyTimers.values()) clearTimeout(timer); canvas.width = canvas.height = 0; window.__esdocColorsCleanup = null; diff --git a/static/js/tools-core.js b/static/js/tools-core.js index 1a76dc7..425bb12 100644 --- a/static/js/tools-core.js +++ b/static/js/tools-core.js @@ -9,6 +9,35 @@ }; const accepts = (path, mode) => (mode !== 'unrpa' && /\.(?:rpym?c|pyc)$/i.test(path)) || (mode !== 'unrpyc' && /\.rpa$/i.test(path)); + const archiveName = value => { + const name = value.trim().replace(/\.rpa$/i, '') + '.rpa'; + if (!name || name === '.rpa' || name.length > 255 || /[\\/:*?"<>|\x00-\x1f]/.test(name) + || name.startsWith('.') || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])\./i.test(name)) { + throw new Error('Укажите имя архива без пути и символов \\ / : * ? " < > |.'); + } + return name; + }; + const packPath = (path, strip = '', prefix = '') => { + path = safePath(path); + if (strip && !path.startsWith(strip)) throw new Error('Удаляемая папка должна быть общей для всех файлов.'); + prefix = prefix.trim().replaceAll('\\', '/').replace(/\/$/, ''); + if (prefix) { + try { prefix = safePath(prefix) + '/'; } + catch { throw new Error('Укажите относительный путь папки без .., начального / и двоеточия. Например: mods/my_mod.'); } + } + return safePath(prefix + path.slice(strip.length)); + }; + const commonFolders = paths => { + if (!paths.length) return []; + const parts = safePath(paths[0]).split('/'); + const folders = []; + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join('/') + '/'; + if (!paths.every(path => path.startsWith(prefix))) break; + folders.push(prefix); + } + return folders; + }; const walkEntry = async (entry, parent, add) => { const path = safePath(parent + entry.name); if (entry.isFile) { @@ -110,5 +139,6 @@ const category = pluralRules.select(count); return `${count.toLocaleString('ru-RU')} ${{ one, few, many }[category] || many}`; }; - root.ESDocTools = { safePath, accepts, walkEntry, size, explainWarning, explainError, countLabel }; + root.ESDocTools = { safePath, accepts, walkEntry, size, explainWarning, explainError, countLabel, + archiveName, packPath, commonFolders }; })(globalThis); diff --git a/static/js/tools-worker.js b/static/js/tools-worker.js index bd94833..f4ec051 100644 --- a/static/js/tools-worker.js +++ b/static/js/tools-worker.js @@ -1,6 +1,7 @@ /* All runtime and Python code is served by ES Doc; user Files stay in this worker. WORKERFS reads Blob slices instead of copying a whole RPA to MEMFS. */ let runtimePromise; +let packRuntimePromise; let inputIndices = []; let browseMounted = false; let commandQueue = Promise.resolve(); @@ -13,13 +14,19 @@ const checkedFetch = async url => { return response; }; -async function initialize(config) { +async function loadRuntime(config) { self.postMessage({ type: 'loading', text: 'Загрузка инструментов для локальной обработки…' }); const { loadPyodide } = await import(new URL(config.runtime + 'pyodide.mjs', self.location.origin).href); const pyodide = await loadPyodide({ indexURL: new URL(config.runtime, self.location.origin).href, stdout: () => {}, stderr: () => {}, }); + if (!pyodide.FS.filesystems.WORKERFS) throw new Error('Браузер не поддерживает чтение файлов инструментом. Попробуйте актуальный Firefox, Chrome или Edge.'); + return pyodide; +} + +async function initialize(config) { + const pyodide = await loadRuntime(config); const [vendor, engine, recovery, syntax, lexer, bytecode, pyc] = await Promise.all([ checkedFetch(config.vendor).then(response => response.arrayBuffer()), checkedFetch(config.engine).then(response => response.text()), @@ -52,6 +59,47 @@ import engine return pyodide; } +async function initializePacker(config) { + const pyodide = await loadRuntime(config); + const packer = await checkedFetch(config.packer).then(response => response.text()); + pyodide.FS.mkdirTree('/tools'); + pyodide.FS.writeFile('/tools/rpa.py', packer, { encoding: 'utf8' }); + await pyodide.runPythonAsync("import sys\nsys.path.insert(0, '/tools')\nimport rpa"); + return pyodide; +} + +async function pack(data) { + let pyodide, mounted = false; + try { + packRuntimePromise ||= initializePacker(data.config).catch(error => { packRuntimePromise = null; throw error; }); + pyodide = await packRuntimePromise; + pyodide.FS.mkdirTree('/pack'); + pyodide.FS.mount(pyodide.FS.filesystems.WORKERFS, { + blobs: data.files.map((entry, index) => ({ name: `input-${index}`, data: entry.file })), + }, '/pack'); + mounted = true; + inputIndices = []; + self.postMessage({ type: 'ready' }); + pyodide.globals.set('pack_json', JSON.stringify(data.files.map((entry, index) => ({ + path: entry.path, source: `/pack/input-${index}`, + })))); + self.postMessage({ type: 'output-start' }); + const result = JSON.parse(await pyodide.runPythonAsync(` +import json +from js import esdocNotify, esdocEmit +pack_result = rpa.pack(json.loads(pack_json), esdocEmit, esdocNotify) +json.dumps(pack_result) +`)); + self.postMessage({ type: 'output', name: data.name, mime: 'application/octet-stream', result }); + self.postMessage({ type: 'done', result }); + } catch (error) { + self.postMessage({ type: 'fatal', error: String(error.message || error) }); + } finally { + if (mounted) pyodide.FS.unmount('/pack'); + if (pyodide) await pyodide.runPythonAsync("globals().pop('pack_json', None)\nglobals().pop('pack_result', None)\nimport gc\ngc.collect()"); + } +} + self.esdocNotify = event => { const message = event.toJs({ dict_converter: Object.fromEntries }); if (typeof message.index === 'number') message.index = inputIndices[message.index] ?? message.index; @@ -227,8 +275,8 @@ gc.collect() } self.onmessage = ({ data }) => { - if (!['run', 'catalog', 'source-read', 'browse-list', 'browse-read'].includes(data.type)) return; - commandQueue = commandQueue.then(() => data.type === 'run' ? run(data) + if (!['pack', 'run', 'catalog', 'source-read', 'browse-list', 'browse-read'].includes(data.type)) return; + commandQueue = commandQueue.then(() => data.type === 'pack' ? pack(data) : data.type === 'run' ? run(data) : ['catalog', 'source-read'].includes(data.type) ? source(data) : browse(data)) .catch(error => self.postMessage({ type: 'fatal', error: String(error.message || error) })); return commandQueue; diff --git a/static/js/tools.js b/static/js/tools.js index 4e7e4f4..88e2d34 100644 --- a/static/js/tools.js +++ b/static/js/tools.js @@ -2,7 +2,8 @@ window.__esdocToolsCleanup?.(); const app = document.getElementById('tools-app'); if (!app) return; - const { accepts, safePath, walkEntry, size, explainWarning, explainError, countLabel } = window.ESDocTools; + const { accepts, safePath, walkEntry, size, explainWarning, explainError, countLabel, + archiveName, packPath, commonFolders } = window.ESDocTools; const config = JSON.parse(app.dataset.config); const get = id => document.getElementById('tools-' + id); const controls = get('controls'), drop = get('drop'), menu = get('add-menu'); @@ -18,9 +19,17 @@ const browserPreview = get('browser-preview'), browserStatus = get('browser-status'); const browserSearch = get('browser-search'), browserCrumbs = get('browser-crumbs'); const errorsPanel = get('errors'); + const packName = get('pack-name'), packStrip = get('pack-strip'), packPrefix = get('pack-prefix'); + let mode = new URLSearchParams(window.location.search).get('mode') === 'pack' ? 'pack' : 'unpack'; + let packValid = true, pathPreviewValid = true; + let validPackPaths = new Map(), queuePage = 0, queueMatches = 0; + const queuePageSize = 100; + const queueSearch = get('queue-search'); + const queuePrevious = get('queue-previous'), queueNext = get('queue-next'); const controller = new AbortController(); const on = (element, event, callback) => element.addEventListener(event, callback, { signal: controller.signal }); - const files = new Map(); + const modeFiles = { pack: new Map(), unpack: new Map() }; + let files = modeFiles[mode]; const rows = new Map(); const supported = Boolean(window.Worker && window.WebAssembly); let busy = false, enumerating = false, disposed = false, retryAvailable = false; @@ -109,16 +118,20 @@ const isArchive = entry => /\.rpa$/i.test(entry.path); const updateControls = () => { const locked = busy || enumerating || !supported; - start.disabled = locked || !selected().length; - start.hidden = !retryAvailable || locked || !selected().length; + start.disabled = locked || !selected().length || (mode === 'pack' && !packValid); + start.hidden = mode === 'pack' || !retryAvailable || locked || !selected().length; addButton.disabled = locked; get('clear').disabled = locked || !selected().length; for (const field of app.querySelectorAll('.tools-options input')) field.disabled = locked; + for (const field of [packName, packStrip, packPrefix, get('mode-pack'), get('mode-unpack')]) field.disabled = locked; for (const button of list.querySelectorAll('button')) button.disabled = locked; + queueSearch.disabled = locked; + queuePrevious.disabled = locked || queuePage === 0; + queueNext.disabled = locked || (queuePage + 1) * queuePageSize >= queueMatches; cancel.hidden = !busy && !enumerating; const cancelHost = busy && task === 'preview' && previewPending ? previewPending : operationActions; if (cancel.parentNode !== cancelHost) cancelHost.appendChild(cancel); - downloadAll.disabled = locked || (!selected().length && !outputs.length); + downloadAll.disabled = locked || (!selected().length && !outputs.length) || (mode === 'pack' && !packValid); browseArchive.disabled = locked; browser.setAttribute('aria-busy', String(busy)); for (const button of browserList.querySelectorAll('button')) button.disabled = locked; @@ -126,22 +139,37 @@ const render = () => { list.replaceChildren(); rows.clear(); - const visible = selected(); - queue.hidden = !visible.length; + const allFiles = selected(); + const query = queueSearch.value.trim().normalize('NFC').toLowerCase(); + const matched = mode === 'pack' && query ? allFiles.filter(entry => + [entry.path, validPackPaths.get(entry.path) || ''].some(path => path.normalize('NFC').toLowerCase().includes(query))) : allFiles; + queueMatches = matched.length; + queuePage = Math.min(queuePage, Math.max(0, Math.ceil(queueMatches / queuePageSize) - 1)); + const first = mode === 'pack' ? queuePage * queuePageSize : 0; + const visible = matched.slice(first, first + (mode === 'pack' ? queuePageSize : 200)); + queue.hidden = !allFiles.length; list.hidden = false; - get('count').textContent = `${countLabel(visible.length, 'файл', 'файла', 'файлов')} / ${size(visible.reduce((total, entry) => total + entry.file.size, 0))}`; + get('count').textContent = `${countLabel(allFiles.length, 'файл', 'файла', 'файлов')} / ${size(allFiles.reduce((total, entry) => total + entry.file.size, 0))}`; + get('queue-tools').hidden = mode !== 'pack' || !allFiles.length; + get('pack-preview-note').hidden = pathPreviewValid; + get('queue-pages').hidden = queueMatches <= queuePageSize; + get('queue-range').textContent = queueMatches ? `${first + 1}–${first + visible.length} из ${queueMatches}` : 'Файлы не найдены. Измените поиск.'; const fragment = document.createDocumentFragment(); // Bound the DOM for folders with thousands of scripts; all queued files // are still processed even when their rows are not rendered. - for (const entry of visible.slice(0, 200)) { + for (const entry of visible) { const row = document.createElement('li'); const name = document.createElement('div'); name.className = 'tools-file-name'; - const open = document.createElement('button'); + const open = document.createElement(mode === 'pack' ? 'span' : 'button'); open.type = 'button'; open.className = 'tools-text-button'; - open.textContent = entry.path; + if (mode === 'pack') { + open.textContent = validPackPaths.get(entry.path) || 'Путь не рассчитан'; + open.className = 'tools-pack-path'; + } else open.textContent = entry.path; open.addEventListener('click', () => { + if (mode === 'pack') return; browseArchive.value = isArchive(entry) ? String(catalogFiles.findIndex(item => item.path === entry.path)) : 'scripts'; openBrowse(); if (!isArchive(entry)) { @@ -152,7 +180,8 @@ name.appendChild(open); const state = document.createElement('span'); state.className = 'tools-file-state'; - state.textContent = 'Добавлен'; + state.textContent = mode === 'pack' && !validPackPaths.has(entry.path) ? 'Исходный файл: ' + entry.path + : mode === 'pack' && open.textContent !== entry.path ? 'Из ' + entry.path : 'Добавлен'; name.appendChild(state); const bytes = document.createElement('span'); bytes.className = 'tools-file-size'; @@ -162,14 +191,28 @@ remove.className = 'tools-remove'; remove.setAttribute('aria-label', 'Убрать ' + entry.path); remove.innerHTML = window.ESDocIcons.svg('x', 16); - remove.addEventListener('click', () => { files.delete(entry.path); clearResult(); retryAvailable = false; render(); loadCatalog(); }); + remove.addEventListener('click', () => { + const focused = document.activeElement === remove; + const index = visible.indexOf(entry); + files.delete(entry.path); + validPackPaths.delete(entry.path); + clearResult(); + retryAvailable = false; + if (mode === 'pack') refreshPackFolders(); + render(); + loadCatalog(); + if (focused) { + const buttons = list.querySelectorAll('.tools-remove'); + (buttons[Math.min(index, buttons.length - 1)] || addButton).focus(); + } + }); row.append(name, bytes, remove); - rows.set(entry.path, state); + rows.set(open.textContent, state); fragment.appendChild(row); } - if (visible.length > 200) { + if (mode !== 'pack' && allFiles.length > 200) { const remainder = document.createElement('li'); - remainder.textContent = `И ещё ${countLabel(visible.length - 200, 'файл', 'файла', 'файлов')}. Будут обработаны все.`; + remainder.textContent = `И ещё ${countLabel(allFiles.length - 200, 'файл', 'файла', 'файлов')}. Будут обработаны все.`; fragment.appendChild(remainder); } list.appendChild(fragment); @@ -189,7 +232,7 @@ await collect(entry => { if (disposed || id !== operation) throw new Error('Добавление отменено.'); entry.path = safePath(entry.path); - if (!accepts(entry.path, 'combined')) { skipped++; return; } + if (mode !== 'pack' && !accepts(entry.path, 'combined')) { skipped++; return; } if (files.has(entry.path)) { duplicates++; return; } files.set(entry.path, entry); added++; @@ -197,12 +240,13 @@ if (id === operation) say(`Добавлено файлов: ${added}.` + (skipped ? ` Другие форматы пропущены: ${skipped}.` : '') + (duplicates ? ` Повторные пути пропущены: ${duplicates}.` : '') - + (!files.size ? ' Выберите .rpyc, .rpymc, .pyc или .rpa.' : '')); + + (!files.size ? (mode === 'pack' ? ' Выберите ресурсы для архива.' : ' Выберите .rpyc, .rpymc, .pyc или .rpa.') : '')); } catch (error) { if (id === operation) showErrors([error.message]); } finally { if (!disposed && id === operation) { enumerating = false; + if (mode === 'pack') refreshPackFolders(); render(); if (added) loadCatalog(); } @@ -246,8 +290,15 @@ resetWorker(); retryAvailable = false; progress.hidden = true; + if (mode === 'pack') { + validPackPaths.clear(); + queueSearch.value = ''; + queuePage = 0; + refreshPackFolders(); + } say('Добавленные файлы удалены.'); render(); + addButton.focus(); }); const resetWorker = () => { @@ -270,10 +321,96 @@ start.classList.remove('tools-button-primary'); downloadAll.hidden = false; downloadAll.innerHTML = '' - + (exportReady ? 'Скачать ещё раз' : 'Скачать всё в ZIP') + ''; - if (outputs.length === 1) outputs[0].link.classList.add('tools-button-primary'); + + (exportReady ? 'Скачать ещё раз' : mode === 'pack' ? 'Создать и скачать RPA' : 'Скачать всё в ZIP') + ''; + if (outputs.length === 1 && mode !== 'pack') outputs[0].link.classList.add('tools-button-primary'); }; const options = () => ({ try_harder: get('try-harder').checked, no_init_offset: get('no-init-offset').checked }); + const validatePack = () => { + let errorMessage = ''; + packName.removeAttribute('aria-invalid'); + packPrefix.removeAttribute('aria-invalid'); + try { + archiveName(packName.value); + } catch (error) { + errorMessage = error.message; + packName.setAttribute('aria-invalid', 'true'); + } + let prefixValid = false; + try { + packPath('resource', '', packPrefix.value); + prefixValid = true; + const paths = new Set(); + const mapped = new Map(); + for (const entry of selected()) { + const path = packPath(entry.path, packStrip.value, packPrefix.value); + const canonical = path.normalize('NFC').toLowerCase(); + if (paths.has(canonical)) throw new Error(`Повторный путь в архиве: ${path}. Уберите один из файлов.`); + paths.add(canonical); + mapped.set(entry.path, path); + } + validPackPaths = mapped; + pathPreviewValid = true; + } catch (error) { + pathPreviewValid = false; + if (!prefixValid) packPrefix.setAttribute('aria-invalid', 'true'); + errorMessage ||= error.message; + } + packValid = !errorMessage; + get('pack-error').textContent = errorMessage; + get('pack-error').hidden = packValid; + return packValid; + }; + const refreshPackFolders = () => { + const previous = packStrip.value; + const initial = packStrip.children.length <= 1; + const folders = commonFolders(selected().map(entry => entry.path)); + packStrip.replaceChildren(); + for (const prefix of ['', ...folders]) { + const option = document.createElement('option'); + option.value = prefix; + option.textContent = prefix || 'Ничего не удалять'; + packStrip.appendChild(option); + } + packStrip.value = initial && folders.includes('game/') ? 'game/' + : folders.includes(previous) ? previous : ''; + validatePack(); + }; + const applyMode = () => { + get('mode-pack').checked = mode === 'pack'; + get('mode-unpack').checked = mode !== 'pack'; + get('pack-settings').hidden = mode !== 'pack'; + get('options').hidden = mode === 'pack'; + get('pack-help').hidden = mode !== 'pack'; + get('unpack-help').hidden = mode === 'pack'; + get('queue-heading').textContent = mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы'; + list.setAttribute('aria-label', mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы'); + get('drop-help').textContent = mode === 'pack' ? 'Изображения, музыка, шрифты и другие файлы' : '.rpa, .rpyc, .rpymc, .pyc'; + fileInput.accept = mode === 'pack' ? '' : '.rpa,.rpyc,.rpymc,.pyc'; + if (mode === 'pack') validatePack(); + }; + on(queueSearch, 'input', () => { queuePage = 0; render(); }); + on(queuePrevious, 'click', () => { queuePage--; render(); }); + on(queueNext, 'click', () => { queuePage++; render(); }); + for (const field of [packName, packPrefix, packStrip]) on(field, field === packStrip ? 'change' : 'input', () => { + clearResult(); + validatePack(); + render(); + if (selected().length) loadCatalog(); + }); + for (const value of ['pack', 'unpack']) on(get('mode-' + value), 'change', () => { + if (busy || enumerating || mode === value) return; + mode = value; + files = modeFiles[mode]; + resetWorker(); + clearResult(); + activeFiles = []; + retryAvailable = false; + fileInput.value = folderInput.value = ''; + applyMode(); + render(); + if (files.size) loadCatalog(); + else say(mode === 'pack' ? 'Добавьте файлы для нового RPA-архива.' : 'Добавьте архивы или скомпилированные скрипты.'); + }); const readBrowse = entry => { if (busy) return; retryAvailable = false; @@ -740,6 +877,11 @@ if (data.type === 'loading') say(data.text); else if (data.type === 'ready') { say('Инструменты готовы. Обработка файлов…'); processedFiles = 0; progressBar.max = activeFiles.length; progressBar.value = 0; } else if (data.type === 'chunk') chunks.push(new Blob([data.buffer])); + else if (data.type === 'pack-progress') { + current.textContent = `${data.path} / ${size(data.current)} из ${size(data.total)}`; + progressBar.max = data.total || 1; + progressBar.value = data.current; + } else if (data.type === 'entry') { current.textContent = `${data.path} / ${data.current} из ${data.total}`; progressBar.value = processedFiles + data.current / data.total; @@ -751,11 +893,11 @@ state.classList.toggle('is-error', data.state === 'error'); } current.textContent = data.path; - if (data.state !== 'working') progressBar.value = ++processedFiles; + if (data.state !== 'working' && mode !== 'pack') progressBar.value = ++processedFiles; } else if (data.type === 'output-start') chunks = []; else if (data.type === 'output') { if (data.result.written) { - const blob = new Blob(chunks, { type: 'application/zip' }); + const blob = new Blob(chunks, { type: data.mime || 'application/zip' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.className = 'tools-button'; @@ -782,13 +924,14 @@ const result = data.result; chunks = []; retryAvailable = false; - clearOnDownload = result.failed === 0; + clearOnDownload = result.failed === 0 && mode !== 'pack'; exportReady = result.failed === 0; finish(); + if (mode === 'pack') render(); resultPanel.hidden = false; if (outputs.length) { showOutputs(); - get('result-description').textContent = `Обработано: ${result.succeeded}.` + get('result-description').textContent = (mode === 'pack' ? `Файлов в RPA: ${result.written}.` : `Обработано: ${result.succeeded}.`) + (result.written !== result.succeeded ? ` Файлов в результате: ${result.written}.` : '') + (result.failed ? ` Ошибок: ${result.failed}.` : ''); renderWarnings(warningsPanel, get('warnings-summary'), warningList, @@ -819,6 +962,15 @@ }; const loadCatalog = () => { if (!supported || busy || enumerating || !selected().length) return; + if (mode === 'pack') { + refreshPackFolders(); + validatePack(); + resultPanel.hidden = false; + downloadAll.hidden = false; + showOutputs(); + updateControls(); + return; + } catalogFiles = selected(); retryAvailable = false; const archives = catalogFiles.map((entry, source) => ({ entry, source })).filter(({ entry }) => isArchive(entry)); @@ -862,6 +1014,7 @@ }; const processFiles = () => { if (!supported || busy || enumerating || !selected().length) return; + if (mode === 'pack' && !validatePack()) return; for (const output of outputs) URL.revokeObjectURL(output.url); outputs.length = 0; downloads.replaceChildren(); @@ -880,8 +1033,9 @@ say('Подготовка к обработке…'); try { ensureWorker(); - worker.postMessage({ type: 'run', config, mode: 'combined', files: activeFiles, - options: options() }); + if (mode === 'pack') worker.postMessage({ type: 'pack', config, name: archiveName(packName.value), + files: activeFiles.map(entry => ({ file: entry.file, path: packPath(entry.path, packStrip.value, packPrefix.value) })) }); + else worker.postMessage({ type: 'run', config, mode: 'combined', files: activeFiles, options: options() }); } catch (error) { fail(error.message); } }; on(start, 'click', loadCatalog); @@ -942,7 +1096,10 @@ controller.abort(); resetWorker(); clearResult(); - files.clear(); + modeFiles.pack.clear(); + modeFiles.unpack.clear(); + validPackPaths.clear(); + activeFiles = []; window.__esdocToolsCleanup = null; }; if (!supported) { @@ -952,6 +1109,6 @@ return; } controls.hidden = false; - get('drop-help').textContent = '.rpa, .rpyc, .rpymc, .pyc'; + applyMode(); render(); })(); diff --git a/static/tools/rpa.py b/static/tools/rpa.py new file mode 100644 index 0000000..1fd61fb --- /dev/null +++ b/static/tools/rpa.py @@ -0,0 +1,74 @@ +"""Stream RPA-3.0 resources with a Python 2 compatible index. + +Format reference: renpy/renpy launcher/game/archiver.rpy and renpy/loader.py. +Files are copied unchanged; only the index is compressed. +""" +import os +import pickle +import unicodedata +import zlib + +CHUNK = 256 * 1024 +KEY = 0x42424242 +HEADER_SIZE = 34 + + +def safe_path(value): + if not isinstance(value, str): + raise ValueError('Путь файла должен быть строкой.') + value = value.replace('\\', '/') + if (not value or value.startswith('/') or ':' in value + or any(ord(c) < 32 for c in value) + or any(part in ('', '.', '..') for part in value.split('/')) + or len(value) > 4096): + raise ValueError(f'Недопустимый путь: {value!r}.') + return value + + +def pack(files, emit, notify=lambda event: None): + """Validate all paths/sizes before emitting a forward-only archive stream.""" + if not files: + raise ValueError('Добавьте файлы для упаковки.') + entries = [] + names = set() + for entry in files: + path = safe_path(entry['path']) + canonical = unicodedata.normalize('NFC', path).lower() + if canonical in names: + raise ValueError(f'Повторный путь в архиве: {path}. Измените пути или уберите файл.') + names.add(canonical) + entries.append((path, entry['source'], os.path.getsize(entry['source']))) + for path, _, _ in entries: + parts = unicodedata.normalize('NFC', path).lower().split('/') + if any('/'.join(parts[:i]) in names for i in range(1, len(parts))): + raise ValueError(f'Папка совпадает с именем файла: {path}. Измените пути.') + entries.sort(key=lambda entry: entry[0]) + offset = HEADER_SIZE + index = {} + for path, _, length in entries: + # Two-element records need no byte-string pickle globals on Python 2. + index[path] = [(offset ^ KEY, length ^ KEY)] + offset += length + compressed_index = zlib.compress(pickle.dumps(index, protocol=2)) + emit(f'RPA-3.0 {offset:016x} {KEY:08x}\n'.encode('ascii')) + total_bytes = offset - HEADER_SIZE + copied = 0 + for path, source, length in entries: + notify({'type': 'file', 'path': path, 'state': 'working'}) + remaining = length + with open(source, 'rb') as resource: + while remaining: + data = resource.read(min(CHUNK, remaining)) + if not data: + raise ValueError(f'Не удалось дочитать файл: {path}. Добавьте его заново.') + emit(data) + copied += len(data) + remaining -= len(data) + notify({'type': 'pack-progress', 'path': path, 'current': copied, 'total': total_bytes}) + if resource.read(1): + raise ValueError(f'Размер файла изменился: {path}. Добавьте его заново.') + notify({'type': 'file', 'path': path, 'state': 'done'}) + for start in range(0, len(compressed_index), CHUNK): + emit(compressed_index[start:start + CHUNK]) + return {'succeeded': len(entries), 'written': len(entries), 'failed': 0, + 'warnings': 0, 'warning_details': [], 'errors': []} diff --git a/templates/tools.html b/templates/tools.html index 1e2860a..14d9898 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -14,7 +14,7 @@

Инструменты

Онлайн-инструменты

diff --git a/templates/tools_unpack.html b/templates/tools_unpack.html index 953cb37..8ae6da0 100644 --- a/templates/tools_unpack.html +++ b/templates/tools_unpack.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% set page_title = 'Распаковщик RPA/RPYC/PYC — ES Doc' %} +{% set page_title = 'Архивы RPA и декомпиляция RPYC/PYC — ES Doc' %} {% set body_class = 'bg-solid scene-page' %} {% set active = 'tools' %} {% set with_unpacker_js = true %} @@ -11,7 +11,7 @@
Инструменты -

Распаковщик RPA/RPYC/PYC

+

Архивы RPA и декомпиляция

@@ -23,6 +23,11 @@

Файлы Ren'Py

-
Подробнее + +
Подробнее

Предпросмотр обрабатывает только выбранный файл. Декомпилированные скрипты сохраняются в unrpyc.zip, для каждого .rpa создаётся отдельный ZIP с папками и файлами архива. Сценарии .rpyc/.rpymc внутри него восстанавливаются в .rpy/.rpym, поддерживаемые .pyc в .py. Если исходник уже есть, он сохраняется без замены.

UnRpyc восстанавливает .rpyc и .rpymc. Файлы Ren'Py 6/7 тоже обрабатываются, но совместимость зависит от версии игры. Комментарии и исходное форматирование в скомпилированных файлах не сохраняются.

diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs new file mode 100644 index 0000000..f529e42 --- /dev/null +++ b/tests/rpa_browser.cjs @@ -0,0 +1,167 @@ +/* Run with ES Doc on port 8012 and Playwright in NODE_PATH. + RPA_TEST_URL and PLAYWRIGHT_CHANNEL can override the defaults. */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { chromium } = require('playwright'); +const root = path.resolve(__dirname, '..'); +const fixtures = path.join(root, 'temp/rpa-browser/game'); +const target = process.env.RPA_TEST_URL || 'http://127.0.0.1:8012/tools/unpack?mode=pack'; +const resources = { + 'mods/my_mod/images/bg.png': Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'), + 'mods/my_mod/audio/тема.ogg': Buffer.from([0, 255, 128, 65]), + 'mods/my_mod/empty.txt': Buffer.alloc(0), + 'mods/my_mod/script.rpy': Buffer.from('label my_mod_start:\n return\n'), +}; +for (const [name, data] of Object.entries(resources)) { + const file = path.join(fixtures, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, data); +} + +(async () => { + const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL || 'msedge' }); + try { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, colorScheme: 'light' }); + const page = await context.newPage(); + const errors = [], requests = []; + page.on('pageerror', error => errors.push(error.message)); + context.on('request', request => requests.push([request.method(), request.url()])); + await page.goto(target); + await page.locator('#tools-pack-settings').waitFor({ state: 'visible' }); + assert(await page.locator('#tools-mode-pack').isChecked()); + await page.locator('#tools-folder').setInputFiles(fixtures); + await page.locator('#tools-download-all').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#tools-pack-strip').inputValue(), 'game/'); + assert.match(await page.locator('#tools-file-list').textContent(), /mods\/my_mod\/images\/bg.png/); + const verifiedPaths = await page.locator('.tools-pack-path').allTextContents(); + await page.locator('#tools-pack-prefix').fill('../outside'); + assert(await page.locator('#tools-download-all').isDisabled()); + assert(await page.locator('#tools-pack-error').isVisible()); + assert.match(await page.locator('#tools-pack-error').textContent(), /относительный путь.*mods\/my_mod/); + assert(await page.locator('#tools-pack-preview-note').isVisible()); + assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + await page.locator('#tools-pack-prefix').fill(''); + await page.locator('#tools-pack-name').fill('my_mod'); + await page.locator('#tools-mode-unpack').check(); + assert(!await page.locator('#tools-queue').isVisible()); + await page.locator('#tools-mode-pack').check(); + assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + assert.equal(await page.locator('#tools-pack-name').inputValue(), 'my_mod'); + await page.locator('#tools-queue-search').fill('bg.png'); + assert.equal(await page.locator('.tools-pack-path').count(), 1); + + // Cancel lazy startup and retry from the same File objects. + await page.locator('#tools-download-all').click(); + await page.locator('#tools-cancel').click(); + assert.match(await page.locator('#tools-status').textContent(), /отменена/); + assert(await page.locator('#tools-queue').isVisible()); + const download = page.waitForEvent('download', { timeout: 90000 }); + await page.locator('#tools-download-all').click(); + const saved = await download; + assert.equal(saved.suggestedFilename(), 'my_mod.rpa'); + const archive = path.join(root, 'temp/rpa-browser/my_mod.rpa'); + await saved.saveAs(archive); + assert.match(await page.locator('#tools-result-description').textContent(), /Файлов в RPA: 4/); + assert.equal(await page.locator('#tools-queue').isVisible(), true); + await page.locator('#tools-queue-search').fill(''); + assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + await page.locator('#tools-pack-prefix').fill('alternate'); + assert.equal(await page.locator('.tools-pack-path').first().textContent(), 'alternate/' + verifiedPaths[0]); + assert(await page.locator('#tools-download-all').isEnabled()); + await page.locator('#tools-pack-prefix').fill(''); + const python = path.join(root, '.venv/Scripts/python.exe'); + execFileSync(python, ['-c', [ + 'import pathlib, pickle, zlib, sys', + 'data = pathlib.Path(sys.argv[1]).read_bytes()', + 'assert data.startswith(b"RPA-3.0 ")', + 'offset, key = int(data[8:24], 16), int(data[25:33], 16)', + 'index = pickle.loads(zlib.decompress(data[offset:]))', + 'for name, records in index.items():', + ' content = b"".join(data[start ^ key:(start ^ key) + (length ^ key)] for start, length in records)', + ' assert content == (pathlib.Path(sys.argv[2]) / name).read_bytes(), name', + 'assert len(index) == 4', + ].join('\n'), archive, fixtures]); + assert(requests.every(([method]) => method === 'GET'), JSON.stringify(requests)); + assert(!requests.some(([, url]) => /vendor\.zip|bytecode\.zip/.test(url)), 'Packing must not load decompilers'); + + // Open our output in the existing unpacker, including Unicode names. + await page.locator('#tools-mode-unpack').check(); + await page.locator('#tools-files').setInputFiles(archive); + await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 }); + await page.locator('#tools-mode-pack').check(); + assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + await page.locator('#tools-mode-unpack').check(); + await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 }); + await page.locator('#tools-browser-search').fill('тема'); + assert.match(await page.locator('#tools-browser-list').textContent(), /тема.ogg/); + const extracted = page.waitForEvent('download', { timeout: 90000 }); + await page.locator('#tools-download-all').click(); + const zip = await extracted; + assert.equal(zip.suggestedFilename(), 'my_mod.zip'); + const zipPath = path.join(root, 'temp/rpa-browser/my_mod.zip'); + await zip.saveAs(zipPath); + execFileSync(python, ['-c', [ + 'import pathlib, zipfile, sys', + 'with zipfile.ZipFile(sys.argv[1]) as archive:', + ' for name in archive.namelist():', + ' assert archive.read(name) == (pathlib.Path(sys.argv[2]) / name).read_bytes(), name', + ' assert len(archive.namelist()) == 4', + ].join('\n'), zipPath, fixtures]); + assert.deepEqual(errors, []); + + // Reach a file beyond the former 200-row limit, then remove it by keyboard. + await page.locator('#tools-mode-pack').check(); + await page.locator('#tools-clear').click(); + await page.locator('#tools-files').setInputFiles(Array.from({ length: 251 }, (_, i) => ({ + name: `item-${String(i).padStart(3, '0')}.bin`, mimeType: 'application/octet-stream', buffer: Buffer.from([i % 256]), + }))); + assert.equal(await page.locator('.tools-pack-path').count(), 100); + await page.locator('#tools-queue-next').click(); + await page.locator('#tools-queue-next').click(); + assert.equal(await page.locator('.tools-pack-path').count(), 51); + assert.match(await page.locator('#tools-queue-range').textContent(), /201–251 из 251/); + await page.locator('#tools-queue-search').fill('item-250'); + await page.locator('.tools-remove').focus(); + await page.keyboard.press('Enter'); + assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); + await page.locator('#tools-queue-search').fill(''); + await page.locator('.tools-remove').first().focus(); + await page.keyboard.press('Enter'); + assert(await page.locator('.tools-remove').first().evaluate(el => el === document.activeElement)); + await page.locator('#tools-clear').click(); + await page.locator('#tools-files').setInputFiles({ name: 'last.txt', mimeType: 'text/plain', buffer: Buffer.from('last') }); + await page.locator('.tools-remove').focus(); + await page.keyboard.press('Enter'); + assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); + + // One visual inspection round, desktop and mobile / light and dark. + const review = path.join(root, '.impeccable/review'); + fs.mkdirSync(review, { recursive: true }); + for (const [name, width, theme] of [['desktop', 1440, 'light'], ['mobile', 390, 'light'], + ['desktop-dark', 1440, 'dark'], ['mobile-dark', 390, 'dark']]) { + await page.setViewportSize({ width, height: 1000 }); + await page.goto(target); + await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); + await page.locator('#tools-folder').setInputFiles(fixtures); + await page.locator('#tools-download-all').waitFor({ state: 'visible' }); + await page.evaluate(() => document.fonts.ready); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), name + ' overflow'); + const contrasts = await page.locator('#tools-pack-settings input, #tools-pack-settings select, #tools-queue-search').evaluateAll(fields => { + const rgb = color => color.match(/[\d.]+/g).slice(0, 3).map(Number); + const luminance = color => rgb(color).map(v => { + v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; + }).reduce((total, v, i) => total + v * [.2126, .7152, .0722][i], 0); + return fields.map(field => { + const style = getComputedStyle(field); + const a = luminance(style.borderTopColor), b = luminance(style.backgroundColor); + return { id: field.id, ratio: (Math.max(a, b) + .05) / (Math.min(a, b) + .05) }; + }); + }); + assert(contrasts.every(({ ratio }) => ratio >= 3), JSON.stringify({ name, contrasts })); + await page.screenshot({ path: path.join(review, name + '.png'), fullPage: true }); + } + console.log('Browser RPA creation, cancellation, extraction, byte comparison, local GET-only processing and responsive checks passed.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/test_tools.py b/tests/test_tools.py index ea3d651..f9b612d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -21,7 +21,10 @@ async def get(self, path): async def test_tools_page_has_local_processing_and_both_pickers(self): response = await self.get('/tools/unpack') self.assertEqual(response.status_code, 200) - self.assertIn('Распаковщик RPA/RPYC/PYC — ES Doc', response.text) + self.assertIn('Архивы RPA и декомпиляция RPYC/PYC — ES Doc', response.text) + self.assertIn('id="tools-mode-pack"', response.text) + self.assertIn('id="tools-pack-strip"', response.text) + self.assertIn('/static/tools/rpa.py?v=', response.text) self.assertIn('webkitdirectory', response.text) self.assertIn('multiple', response.text) self.assertIn('tools-worker.js', response.text) @@ -68,7 +71,7 @@ async def test_engine_and_runtime_are_served_locally(self): self.assertIn('color: var(--attention)', stylesheet.text) self.assertIn('.tools-status.is-error { color: var(--danger); }', stylesheet.text) self.assertIn('--tools-error-icon:', stylesheet.text) - for path in ('engine.py', 'recovery.py', 'pyc_decompiler.py', 'bytecode.zip', 'vendor.zip', 'syntax.zip', 'renpy_lexer.py', 'pyodide/0.29.3/pyodide.mjs', 'pyodide/0.29.3/pyodide.asm.wasm'): + for path in ('engine.py', 'rpa.py', 'recovery.py', 'pyc_decompiler.py', 'bytecode.zip', 'vendor.zip', 'syntax.zip', 'renpy_lexer.py', 'pyodide/0.29.3/pyodide.mjs', 'pyodide/0.29.3/pyodide.asm.wasm'): response = await self.get('/static/tools/' + path) self.assertEqual(response.status_code, 200, path) self.assertIn('nosniff', response.headers['x-content-type-options']) diff --git a/tests/test_tools_rpa.py b/tests/test_tools_rpa.py new file mode 100644 index 0000000..0e631bb --- /dev/null +++ b/tests/test_tools_rpa.py @@ -0,0 +1,83 @@ +import pickle +import pickletools +import sys +import unittest +import zlib +from pathlib import Path +from tempfile import TemporaryDirectory + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'static/tools')) +sys.path.insert(0, str(ROOT / 'static/tools/vendor.zip')) +import rpa +from unrpa import UnRPA + + +class RpaPackingTests(unittest.TestCase): + def setUp(self): + self.directory = TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def entries(self, resources): + entries = [] + for number, (name, data) in enumerate(resources.items()): + source = self.root / str(number) + source.write_bytes(data) + entries.append({'path': name, 'source': str(source)}) + return entries + + def test_round_trip_with_unrpa_and_legacy_renpy_records(self): + resources = { + 'mods/my_mod/images/bg.png': bytes(range(256)) * 2100, + 'mods/my_mod/audio/тема.ogg': b'\x00\xff\x80music', + 'mods/my_mod/пустой.txt': b'', + 'mods/my_mod/script.rpy': b'label start:\n return\n', + 'mods/my_mod/straße.txt': b'one', + 'mods/my_mod/strasse.txt': b'two', + } + chunks, events = [], [] + result = rpa.pack(self.entries(resources), chunks.append, events.append) + data = b''.join(chunks) + self.assertTrue(all(len(chunk) <= rpa.CHUNK for chunk in chunks)) + self.assertEqual(result['written'], len(resources)) + self.assertEqual(events[-1]['state'], 'done') + # Read the header/index as Ren'Py 6 does, independently of our packer. + offset = int(data[8:24], 16) + key = int(data[25:33], 16) + index_bytes = zlib.decompress(data[offset:]) + self.assertEqual(index_bytes[:2], b'\x80\x02') + self.assertTrue(all(op.proto <= 2 for op, _, _ in pickletools.genops(index_bytes))) + self.assertFalse(any(op.name in ('GLOBAL', 'STACK_GLOBAL') + for op, _, _ in pickletools.genops(index_bytes))) + index = pickle.loads(index_bytes) + restored = {name: b''.join(data[start ^ key:(start ^ key) + (length ^ key)] + for start, length in records) + for name, records in index.items()} + self.assertEqual(restored, resources) + archive = self.root / 'my_mod.rpa' + archive.write_bytes(data) + (self.root / 'unpacked').mkdir() + unpacker = UnRPA(str(archive), path=str(self.root / 'unpacked')) + unpacker.extract_files() + self.assertEqual({name: (self.root / 'unpacked' / name).read_bytes() + for name in resources}, resources) + + def test_invalid_or_ambiguous_paths_emit_no_partial_archive(self): + for names in [[], ['../outside'], ['/absolute'], ['a//b'], ['C:\\asset'], + ['a.txt', 'A.txt'], ['café', 'cafe\u0301'], ['x', 'x/file']]: + with self.subTest(names=names): + entries = self.entries(dict.fromkeys(names, b'content')) + chunks = [] + with self.assertRaises(ValueError): + rpa.pack(entries, chunks.append) + self.assertEqual(chunks, []) + + def test_missing_input_emits_no_archive(self): + with self.assertRaises(OSError): + rpa.pack([{'path': 'missing', 'source': str(self.root / 'absent')}], + lambda data: self.fail('Invalid input emitted output')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/tools_core.test.mjs b/tests/tools_core.test.mjs index f60b90e..e51ddb8 100644 --- a/tests/tools_core.test.mjs +++ b/tests/tools_core.test.mjs @@ -7,6 +7,23 @@ const context = vm.createContext({}); vm.runInContext(fs.readFileSync(new URL('../static/js/tools-core.js', import.meta.url), 'utf8'), context); const core = context.ESDocTools; +test('packing maps selected game paths and preserves mod namespaces', () => { + const paths = ['game/mods/demo/images/bg.png', 'game/mods/demo/audio/тема.ogg']; + assert.deepEqual([...core.commonFolders(paths)], ['game/', 'game/mods/', 'game/mods/demo/']); + assert.equal(core.packPath(paths[0], 'game/'), 'mods/demo/images/bg.png'); + assert.equal(core.packPath('images/bg.png', '', 'mods/demo/'), 'mods/demo/images/bg.png'); + assert.equal(core.packPath('demo/images/bg.png'), 'demo/images/bg.png'); + assert.throws(() => core.packPath('a.png', 'game/')); + assert.throws(() => core.packPath('a.png', '', '../outside')); + assert.deepEqual([...core.commonFolders(['one/a', 'two/b'])], []); +}); + +test('RPA download names cannot turn into paths or reserved Windows devices', () => { + assert.equal(core.archiveName(' my_mod '), 'my_mod.rpa'); + assert.equal(core.archiveName('Ресурсы.RPA'), 'Ресурсы.rpa'); + for (const name of ['', '../a', '/a', 'a:b', 'CON', 'nul.rpa']) assert.throws(() => core.archiveName(name)); +}); + test('PYC is accepted and unsupported versions have specific advice', () => { assert.equal(core.accepts('module.PYC', 'combined'), true) assert.equal(core.accepts('module.pyc', 'unrpyc'), true) diff --git a/tests/tools_pyodide.test.mjs b/tests/tools_pyodide.test.mjs index 1c762a1..58ad6a6 100644 --- a/tests/tools_pyodide.test.mjs +++ b/tests/tools_pyodide.test.mjs @@ -15,6 +15,7 @@ test('real Pyodide processes RPYC and RPA, then lists and previews each result', pyodide.FS.mkdirTree('/catalog'); pyodide.FS.writeFile('/tools/vendor.zip', fs.readFileSync(path.join(root, 'static/tools/vendor.zip'))); pyodide.FS.writeFile('/tools/engine.py', fs.readFileSync(path.join(root, 'static/tools/engine.py'))); + pyodide.FS.writeFile('/tools/rpa.py', fs.readFileSync(path.join(root, 'static/tools/rpa.py'))); pyodide.FS.writeFile('/tools/recovery.py', fs.readFileSync(path.join(root, 'static/tools/recovery.py'))); pyodide.FS.writeFile('/tools/pyc_decompiler.py', fs.readFileSync(path.join(root, 'static/tools/pyc_decompiler.py'))) pyodide.FS.writeFile('/tools/bytecode.zip', fs.readFileSync(path.join(root, 'static/tools/bytecode.zip'))) @@ -30,6 +31,18 @@ with zipfile.ZipFile('/tools/syntax.zip') as bundled: with zipfile.ZipFile('/tools/bytecode.zip') as bundled: bundled.extractall('/tools') import engine +import rpa +with open('/packed-resource', 'wb') as source: + source.write(b'\x00\xfflocal-resource') +packed_chunks = [] +rpa.pack([{'path': 'mods/my_mod/ресурс.bin', 'source': '/packed-resource'}], packed_chunks.append) +with open('/catalog/packed.rpa', 'wb') as source: + source.write(b''.join(packed_chunks)) +packed_catalog = engine.Catalog([{'path': 'packed.rpa', 'source': '/catalog/packed.rpa'}]) +assert not packed_catalog.errors, packed_catalog.errors +packed_listing = packed_catalog.listing() +assert packed_listing[0]['path'] == 'mods/my_mod/ресурс.bin' +assert packed_catalog.read(packed_listing[0]['id'], {})[1] == b'\x00\xfflocal-resource' pyc_data = bytes.fromhex('330d0d0a0000000000000000e30000000000000000000000000100000000000000730800000064005a00640153002802000000692a0000004e28010000007506000000616e73776572280000000028000000002800000000750a0000006578616d706c652e707975080000003c6d6f64756c653e010000007300000000') with open('/module.pyc', 'wb') as source: source.write(pyc_data) diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs index 0d60ec8..b518e93 100644 --- a/tests/tools_ui.test.mjs +++ b/tests/tools_ui.test.mjs @@ -21,7 +21,18 @@ function page(search = '', withClipboard = false) { getBoundingClientRect() { return { left: 0, bottom: 40, width: 210, height: 100 }; } setAttribute(key, value) { this.attributes[key] = value; } removeAttribute(key) { delete this.attributes[key]; } - querySelectorAll() { return []; } + querySelectorAll(selector) { + const matches = []; + const visit = element => { + for (const child of element.children) { + if (selector === '.tools-remove' && child.className === 'tools-remove') matches.push(child); + if (selector === 'button' && child.tagName === 'BUTTON') matches.push(child); + visit(child); + } + }; + visit(this); + return matches; + } replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this.append(...children); @@ -93,6 +104,127 @@ function page(search = '', withClipboard = false) { } const script = { id: '0:script.rpyc', path: 'script.rpyc', source: 0, size: 1 }; +test('packing accepts resources and sends paths relative to game without cataloging them', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-name').emit('input'); + ui.get('folder').files = [ + { name: 'bg.png', webkitRelativePath: 'game/mods/demo/images/bg.png', size: 3 }, + { name: 'theme.ogg', webkitRelativePath: 'game/mods/demo/audio/theme.ogg', size: 4 }, + ]; + ui.get('folder').emit('change'); + await Promise.resolve(); + assert.equal(ui.messages.length, 0); + assert.equal(ui.get('pack-strip').value, 'game/'); + assert.equal(ui.get('file-list').children[0].children[0].children[0].children[0].textContent, + 'mods/demo/images/bg.png'); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).type, 'pack'); + assert.equal(ui.messages.at(-1).name, 'demo.rpa'); + assert.equal(ui.messages.at(-1).files[0].path, 'mods/demo/images/bg.png'); + ui.send({ type: 'output-start' }); + ui.send({ type: 'chunk', buffer: new Uint8Array([1, 2]).buffer }); + ui.send({ type: 'output', name: 'demo.rpa', mime: 'application/octet-stream', result: ui.result }); + ui.send({ type: 'done', result: ui.result }); + assert.equal(ui.get('downloads').children[0].download, 'demo.rpa'); + assert.equal(ui.blobs.at(-1).type, 'application/octet-stream'); + assert.equal(ui.get('queue').hidden, false); + ui.get('pack-prefix').value = 'next'; + ui.get('pack-prefix').emit('input'); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).files[0].path, 'next/mods/demo/images/bg.png'); +}); + +test('mode switches retain separate queues and packing settings', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-prefix').value = 'mods/demo'; + ui.get('pack-prefix').emit('input'); + await ui.add(['bg.png']); + ui.get('mode-unpack').emit('change'); + assert.equal(ui.get('queue').hidden, true); + await ui.add(['script.rpyc'], [script]); + ui.get('mode-pack').emit('change'); + assert.equal(ui.get('queue').hidden, false); + assert.equal(ui.get('pack-prefix').value, 'mods/demo'); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).files[0].path, 'mods/demo/bg.png'); + ui.exportDone(['demo.rpa']); + ui.get('mode-unpack').emit('change'); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).files[0].path, 'script.rpyc'); +}); + +test('packing pagination and search reach all files without filtering export', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-name').emit('input'); + await ui.add(Array.from({ length: 251 }, (_, i) => `item-${String(i).padStart(3, '0')}.bin`)); + const rows = () => ui.get('file-list').children[0].children; + assert.equal(rows().length, 100); + ui.get('queue-next').click(); + ui.get('queue-next').click(); + assert.equal(rows().length, 51); + assert.equal(ui.get('queue-range').textContent, '201–251 из 251'); + ui.get('queue-search').value = 'item-250'; + ui.get('queue-search').emit('input'); + assert.equal(rows().length, 1); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).files.length, 251); + ui.exportDone(['demo.rpa']); + const remove = ui.get('file-list').querySelectorAll('.tools-remove')[0]; + remove.focus(); remove.click(); + assert.equal(ui.document.activeElement, ui.get('add')); + ui.get('queue-search').value = ''; + ui.get('queue-search').emit('input'); + const first = ui.get('file-list').querySelectorAll('.tools-remove')[0]; + first.focus(); first.click(); + assert.equal(ui.document.activeElement, ui.get('file-list').querySelectorAll('.tools-remove')[0]); + ui.get('clear').click(); + assert.equal(ui.get('queue').hidden, true); + assert.equal(ui.document.activeElement, ui.get('add')); +}); + +test('an invalid prefix preserves the verified path preview and explains how to correct it', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-prefix').value = 'mods/demo'; + ui.get('pack-prefix').emit('input'); + await ui.add(['bg.png']); + const path = () => ui.get('file-list').children[0].children[0].children[0].children[0].textContent; + assert.equal(path(), 'mods/demo/bg.png'); + ui.get('pack-prefix').value = '../bad'; + ui.get('pack-prefix').emit('input'); + assert.equal(path(), 'mods/demo/bg.png'); + assert.equal(ui.get('pack-preview-note').hidden, false); + assert.match(ui.get('pack-error').textContent, /относительный путь.*mods\/my_mod/); + assert.equal(ui.get('download-all').disabled, true); + ui.get('pack-prefix').value = 'fixed'; + ui.get('pack-prefix').emit('input'); + assert.equal(path(), 'fixed/bg.png'); + assert.equal(ui.get('pack-preview-note').hidden, true); +}); + +test('invalid pack settings disable export and cancelling preserves inputs for a new worker', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-name').emit('input'); + await ui.add(['image.png']); + ui.get('pack-prefix').value = '../outside'; + ui.get('pack-prefix').emit('input'); + assert.equal(ui.get('pack-error').hidden, false); + assert.equal(ui.get('download-all').disabled, true); + ui.get('pack-prefix').value = 'mods/demo'; + ui.get('pack-prefix').emit('input'); + ui.get('download-all').click(); + ui.get('cancel').click(); + assert.equal(ui.workers[0].terminated, true); + assert.equal(ui.get('queue').hidden, false); + ui.get('download-all').click(); + assert.equal(ui.workers.length, 2); + assert.equal(ui.messages.at(-1).files[0].path, 'mods/demo/image.png'); +}); + test('PYC accepts uploads and shows Python source preview', async () => { const ui = page() const entry = { id: '0:module.pyc', path: 'module.pyc', source: 0, size: 100 } From 75e7d8e57c342b8de00fa31a1ad6a1cf02bdaf3e Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:07:10 +0300 Subject: [PATCH 02/27] feature: warning if eyedropper isn't available in user's browser --- static/js/colors.js | 7 ++++++- tests/colors_ui.test.mjs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/static/js/colors.js b/static/js/colors.js index db1cb99..0fed362 100644 --- a/static/js/colors.js +++ b/static/js/colors.js @@ -457,8 +457,8 @@ }); const nativeEyedropper = window.EyeDropper && window.isSecureContext; const screenEyedropper = window.isSecureContext && window.navigator.mediaDevices?.getDisplayMedia; + get('eyedropper').hidden = false; if (nativeEyedropper || screenEyedropper) { - get('eyedropper').hidden = false; on(get('eyedropper'), 'click', async () => { const pickController = new AbortController(); eyedropperController = pickController; @@ -501,6 +501,11 @@ } }); } + if (!nativeEyedropper && !screenEyedropper) { + on(get('eyedropper'), 'click', () => { + get('status').textContent = 'Пипетка недоступна в вашем браузере.'; + }); + } const canvas = get('image-canvas'); const imageContext = () => canvas.getContext('2d', {willReadFrequently: true}); const placeImagePoint = () => { diff --git a/tests/colors_ui.test.mjs b/tests/colors_ui.test.mjs index d3f589f..608fced 100644 --- a/tests/colors_ui.test.mjs +++ b/tests/colors_ui.test.mjs @@ -105,6 +105,18 @@ test('changing palette and selecting a swatch resets alpha and invalid inputs', assert.equal(ui.get('swatches').children[0].children[3].getAttribute('aria-pressed'), 'true'); }); +test('unavailable pipette stays visible and explains browser support on click', () => { + for (const options of [{isSecureContext: true}, {isSecureContext: false, EyeDropper: class {}}]) { + const ui = page(options); + const original = ui.get('output-hex').value; + assert.equal(ui.get('eyedropper').hidden, false); + ui.get('eyedropper').click(); + assert.equal(ui.get('status').textContent, 'Пипетка недоступна в вашем браузере.'); + assert.equal(ui.get('output-hex').value, original); + assert.equal(ui.get('eyedropper-overlay').hidden, true); + } +}); + test('right click or Escape cancels the eyedropper without applying a late result', async () => { let resolvePick, signal; const ui = page({isSecureContext: true, EyeDropper: class { From c5a9339890558cc3383ee3d3e00a647658a979dd Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:08:07 +0300 Subject: [PATCH 03/27] feature: rpa packer + color picker fixes --- app/utils/lifespan/search_cache.py | 2 +- static/css/colors.css | 7 ++- static/css/tools.css | 28 ++++++++- static/js/colors.js | 60 ++++++++++++++++---- static/js/tools-core.js | 2 +- static/js/tools.js | 37 ++++++++++-- templates/tools.html | 2 +- templates/tools_colors.html | 3 +- templates/tools_unpack.html | 72 +++++++++++++++++------ tests/colors_ui.test.mjs | 91 +++++++++++++++++++++++++++++- tests/rpa_browser.cjs | 9 ++- tests/test_tools.py | 2 +- tests/tools_ui.test.mjs | 59 ++++++++++++++++++- 13 files changed, 325 insertions(+), 49 deletions(-) diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py index adfa153..e6227ff 100644 --- a/app/utils/lifespan/search_cache.py +++ b/app/utils/lifespan/search_cache.py @@ -17,7 +17,7 @@ ('Специалисты', '/specialists'), ('Материалы', '/materials'), ('Инструменты', '/tools'), - ('Упаковщик и распаковщик RPA / Декомпиляция RPYC/PYC / RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'), + ('Архивация RPA и декомпиляция RPYC/PYC / Упаковщик и распаковщик RPA / RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'), ('Палитра цветов / Цвет / HEX / RGB / Палитры', '/tools/colors'), ('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'), ('Площадки для публикации', '/news-resources'), diff --git a/static/css/colors.css b/static/css/colors.css index 96ef6c6..07a52b6 100644 --- a/static/css/colors.css +++ b/static/css/colors.css @@ -15,6 +15,10 @@ .picker-current span { display: block; width: 100%; height: 100%; } .picker-button, .picker-icon { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; flex: none; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; padding: 8px 12px; } .picker-icon { width: 44px; border-color: transparent; padding: 10px; color: var(--text-soft); } +.picker-eyedropper-warning { position: relative; display: inline-flex; flex: none; } +.picker-eyedropper-warning .picker-icon { width: 32px; padding: 6px; color: var(--warning); } +.picker-eyedropper-tooltip { position: absolute; top: 100%; right: 0; z-index: 10; width: min(300px, calc(100vw - 48px)); padding-top: 8px; box-sizing: border-box; transform: translateX(var(--tooltip-shift, 0px)); } +.picker-eyedropper-tooltip > span { display: block; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font-size: var(--fs-ui); line-height: 1.5; overflow-wrap: anywhere; } .picker-button:hover:enabled, .picker-icon:hover:enabled { color: var(--accent); background: var(--bg-light); } .picker-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); } .picker-primary:hover:enabled { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--bg); } @@ -72,7 +76,8 @@ .picker-shortcuts p { color: var(--text-soft); margin: 12px 0; } .picker-shortcuts .picker-section-head { margin-bottom: 20px; } .picker-eyedropper-overlay { position: fixed; inset: 0; z-index: 10000; background: rgb(0 0 0 / .6); cursor: crosshair; } -.picker-eyedropper-overlay-capture { background: transparent; } +.picker-eyedropper-overlay-capture { background: var(--bg); } +.picker-screen-canvas { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); max-width: calc(100% - 32px); max-height: calc(100% - 220px); width: auto; height: auto; } .picker-eyedropper-help { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: min(360px, calc(100% - 32px)); box-sizing: border-box; padding: 20px 24px; border-radius: var(--radius-block); background: var(--bg); color: var(--text); box-shadow: 0 8px 32px #0004; } .picker-eyedropper-help h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: var(--fs-body); } .picker-eyedropper-help p { display: flex; align-items: center; gap: 16px; margin: 8px 0; font-size: var(--fs-ui); } diff --git a/static/css/tools.css b/static/css/tools.css index f44a933..9c47cad 100644 --- a/static/css/tools.css +++ b/static/css/tools.css @@ -5,12 +5,34 @@ .tools-mode label { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; } .tools-mode label:has(:checked) { font-weight: 600; } .tools-pack-settings { margin-block: 20px 24px; } -.tools-pack-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 12px; } -.tools-pack-fields label { display: grid; gap: 8px; font-size: var(--fs-ui); font-weight: 600; min-width: 0; } -.tools-pack-fields :is(input, select) { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--text-soft); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-weight: 400; caret-color: var(--accent); } +.tools-pack-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 20px; } +.tools-pack-settings label { display: grid; gap: 8px; font-size: var(--fs-ui); font-weight: 600; min-width: 0; } +.tools-pack-settings :is(input, select) { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--text-soft); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-weight: 400; caret-color: var(--accent); } .tools-pack-fields input::placeholder { color: var(--text-soft); opacity: 1; } .tools-pack-fields [aria-invalid="true"] { border-color: var(--danger); } @media (max-width: 700px) { .tools-pack-fields { grid-template-columns: minmax(0, 1fr); } } +.tools-pack-name-field { max-width: 34rem; margin-bottom: 28px; } +.tools-pack-settings .tools-meta { margin-top: 8px; } +.tools-pack-path-settings { border: 0; padding: 0; margin: 0; min-width: 0; } +.tools-pack-path-settings legend { font-size: var(--fs-body); font-weight: 600; padding: 0; } +.tools-pack-path-settings > p { margin: 8px 0 20px; } +.tools-pack-example { background: var(--bg-light); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; } +.tools-pack-example p { margin: 0; } +.tools-pack-path-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0 0; } +.tools-pack-path-comparison dt { color: var(--text-soft); font-size: var(--fs-ui); margin-bottom: 6px; } +.tools-pack-path-comparison dd { margin: 0; overflow-wrap: anywhere; } +#tools-app[data-mode="pack"] .tools-export { flex-direction: column; align-items: flex-start; } +.tools-pack-notice { margin-bottom: 4px; max-width: 70ch; font-size: var(--fs-ui); } +.tools-pack-notice p { margin: 0 0 8px; } +.tools-pack-integration { margin-top: 32px; max-width: 70ch; } +.tools-pack-integration h3 { font-size: var(--fs-body); margin: 0 0 12px; } +.tools-pack-integration p { margin: 12px 0; } +.tools-pack-integration a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.tools-pack-install-code pre { font-size: var(--fs-ui); margin: 12px 0; } +.tools-pack-notice a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.tools-pack-path-comparison code { white-space: normal; overflow-wrap: anywhere; } +#tools-app[data-mode="pack"] .tools-clear { width: auto; gap: 8px; padding-inline: 8px; font: inherit; font-size: var(--fs-ui); } +@media (max-width: 700px) { .tools-pack-path-comparison { grid-template-columns: minmax(0, 1fr); } } .tools-drop { border: 1px dashed var(--border); padding: 30px 20px 22px; text-align: center; background: var(--bg-light); border-radius: var(--radius-sm); } .tools-drop.is-dragging { border-color: var(--accent); border-style: solid; background: var(--accent-muted); } .tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; } diff --git a/static/js/colors.js b/static/js/colors.js index 0fed362..7df241f 100644 --- a/static/js/colors.js +++ b/static/js/colors.js @@ -439,25 +439,52 @@ on(document, 'keydown', event => { if (event.key === 'Escape') cancelEyedropper(event); }); on(get('eyedropper-overlay'), 'pointerdown', event => { if (!screenCaptureVideo || event.button !== 0 || get('eyedropper-overlay').hidden) return; - if (event.target.closest?.('.picker-eyedropper-help')) return; + const sampleCanvas = get('screen-canvas'); + if (event.target !== sampleCanvas) return; event.preventDefault(); try { - const sampleCanvas = document.createElement('canvas'); - const scale = Math.min(1, 4096 / screenCaptureVideo.videoWidth, 4096 / screenCaptureVideo.videoHeight, Math.sqrt(12000000 / (screenCaptureVideo.videoWidth * screenCaptureVideo.videoHeight))); - sampleCanvas.width = Math.max(1, Math.round(screenCaptureVideo.videoWidth * scale)); sampleCanvas.height = Math.max(1, Math.round(screenCaptureVideo.videoHeight * scale)); + const rect = sampleCanvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; const context = sampleCanvas.getContext('2d', {willReadFrequently: true}); - context.drawImage(screenCaptureVideo, 0, 0, sampleCanvas.width, sampleCanvas.height); - const x = c.clamp(Math.floor(event.clientX / window.innerWidth * screenCaptureVideo.videoWidth * scale), 0, sampleCanvas.width - 1); - const y = c.clamp(Math.floor(event.clientY / window.innerHeight * screenCaptureVideo.videoHeight * scale), 0, sampleCanvas.height - 1); + const x = c.clamp(Math.floor((event.clientX - rect.left) / rect.width * sampleCanvas.width), 0, sampleCanvas.width - 1); + const y = c.clamp(Math.floor((event.clientY - rect.top) / rect.height * sampleCanvas.height), 0, sampleCanvas.height - 1); const pixel = context.getImageData(x, y, 1, 1).data; screenPickResolve?.({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] / 255}); } catch (_) { - get('status').textContent = 'Не удалось прочитать цвет экрана. Выберите вкладку Firefox в окне захвата.'; + get('status').textContent = 'Не удалось прочитать цвет снимка. Попробуйте снова или откройте изображение.'; } }); const nativeEyedropper = window.EyeDropper && window.isSecureContext; const screenEyedropper = window.isSecureContext && window.navigator.mediaDevices?.getDisplayMedia; get('eyedropper').hidden = false; + get('eyedropper-warning').hidden = !/(?:Firefox|FxiOS)\//.test(window.navigator.userAgent || ''); + const warning = get('eyedropper-warning'); + const warningTrigger = get('eyedropper-warning-trigger'); + const warningTooltip = get('eyedropper-tooltip'); + let warningTimer, warningHovered = false, warningFocused = false; + const hideWarning = () => { clearTimeout(warningTimer); warningTooltip.hidden = true; }; + const showWarning = () => { + clearTimeout(warningTimer); + if (disposed || warning.hidden) return; + warningTooltip.hidden = false; + warningTooltip.style.setProperty('--tooltip-shift', '0px'); + const rect = warningTooltip.getBoundingClientRect(); + if (Number.isFinite(window.innerWidth)) { + const shift = Math.max(8 - rect.left, Math.min(0, window.innerWidth - 8 - rect.left - rect.width)); + warningTooltip.style.setProperty('--tooltip-shift', `${shift}px`); + } + }; + on(warning, 'pointerenter', event => { + if (event.pointerType === 'touch') return; + warningHovered = true; + warningTimer = setTimeout(showWarning, 200); + }); + on(warning, 'pointerleave', () => { warningHovered = false; clearTimeout(warningTimer); if (!warningFocused) hideWarning(); }); + on(warningTrigger, 'focus', () => { warningFocused = true; showWarning(); }); + on(warningTrigger, 'blur', () => { warningFocused = false; if (!warningHovered) hideWarning(); }); + on(warningTrigger, 'click', showWarning); + on(document, 'keydown', event => { if (event.key === 'Escape' && !warningTooltip.hidden) hideWarning(); }); + controller.signal.addEventListener('abort', hideWarning, {once: true}); if (nativeEyedropper || screenEyedropper) { on(get('eyedropper'), 'click', async () => { const pickController = new AbortController(); @@ -473,16 +500,24 @@ eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300); result = c.parseHex((await new window.EyeDropper().open({signal: pickController.signal})).sRGBHex); } else { - get('status').textContent = 'В окне Firefox выберите эту вкладку для захвата экрана.'; - screenCaptureStream = await window.navigator.mediaDevices.getDisplayMedia({video: true, audio: false, preferCurrentTab: true}); + get('status').textContent = 'Выберите экран или окно для снимка.'; + screenCaptureStream = await window.navigator.mediaDevices.getDisplayMedia({video: true, audio: false}); + if (pickController.signal.aborted || disposed) return; screenCaptureVideo = document.createElement('video'); screenCaptureVideo.muted = true; screenCaptureVideo.playsInline = true; screenCaptureVideo.srcObject = screenCaptureStream; await screenCaptureVideo.play(); if (!screenCaptureVideo.videoWidth || !screenCaptureVideo.videoHeight) throw new Error('Captured tab is not ready'); if (pickController.signal.aborted || disposed) return; + const sampleCanvas = get('screen-canvas'); + const scale = Math.min(1, 4096 / screenCaptureVideo.videoWidth, 4096 / screenCaptureVideo.videoHeight, Math.sqrt(12000000 / (screenCaptureVideo.videoWidth * screenCaptureVideo.videoHeight))); + sampleCanvas.width = Math.max(1, Math.round(screenCaptureVideo.videoWidth * scale)); + sampleCanvas.height = Math.max(1, Math.round(screenCaptureVideo.videoHeight * scale)); + sampleCanvas.getContext('2d', {willReadFrequently: true}).drawImage(screenCaptureVideo, 0, 0, sampleCanvas.width, sampleCanvas.height); + screenCaptureStream.getTracks().forEach(track => track.stop()); + sampleCanvas.hidden = false; get('eyedropper-overlay').classList.add('picker-eyedropper-overlay-capture'); get('eyedropper-overlay').hidden = false; - get('status').textContent = 'Нажмите на нужный цвет. Esc отменяет выбор.'; + get('status').textContent = 'Выберите цвет на снимке. Esc — отмена.'; result = await new Promise((resolve, reject) => { screenPickResolve = resolve; pickController.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true}); @@ -490,13 +525,14 @@ } if (!disposed && !pickController.signal.aborted) choose(result); } - catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : error.name === 'NotAllowedError' ? 'Захват вкладки отменён. Разрешите его или выберите цвет из изображения.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; } + catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : error.name === 'NotAllowedError' ? 'Захват экрана или окна отменён. Разрешите его или выберите цвет из изображения.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; } finally { controller.signal.removeEventListener('abort', abortPick); if (eyedropperController === pickController) eyedropperController = null; screenPickResolve = null; screenCaptureVideo = null; screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null; clearTimeout(eyedropperOverlayTimer); + get('screen-canvas').hidden = true; if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture'); get('eyedropper').focus({preventScroll: true}); } } }); diff --git a/static/js/tools-core.js b/static/js/tools-core.js index 425bb12..6ca49db 100644 --- a/static/js/tools-core.js +++ b/static/js/tools-core.js @@ -23,7 +23,7 @@ prefix = prefix.trim().replaceAll('\\', '/').replace(/\/$/, ''); if (prefix) { try { prefix = safePath(prefix) + '/'; } - catch { throw new Error('Укажите относительный путь папки без .., начального / и двоеточия. Например: mods/my_mod.'); } + catch { throw new Error('Укажите относительный путь папки без .., начального / и двоеточия. Например: my_mod.'); } } return safePath(prefix + path.slice(strip.length)); }; diff --git a/static/js/tools.js b/static/js/tools.js index 88e2d34..699ec82 100644 --- a/static/js/tools.js +++ b/static/js/tools.js @@ -327,12 +327,19 @@ const options = () => ({ try_harder: get('try-harder').checked, no_init_offset: get('no-init-offset').checked }); const validatePack = () => { let errorMessage = ''; + let nameError = '', prefixError = '', pathError = ''; packName.removeAttribute('aria-invalid'); packPrefix.removeAttribute('aria-invalid'); try { - archiveName(packName.value); + const filename = archiveName(packName.value); + get('pack-filename').textContent = filename; + get('pack-install-archive').textContent = 'game/my_mod/' + filename; + get('pack-install-string').textContent = JSON.stringify('my_mod/' + filename.slice(0, -4)); + get('pack-install-example').hidden = false; } catch (error) { - errorMessage = error.message; + nameError = error.message; + get('pack-filename').textContent = 'Укажите имя архива'; + get('pack-install-example').hidden = true; packName.setAttribute('aria-invalid', 'true'); } let prefixValid = false; @@ -352,12 +359,26 @@ pathPreviewValid = true; } catch (error) { pathPreviewValid = false; - if (!prefixValid) packPrefix.setAttribute('aria-invalid', 'true'); - errorMessage ||= error.message; + if (!prefixValid) { + packPrefix.setAttribute('aria-invalid', 'true'); + prefixError = error.message; + } else pathError = error.message; } + for (const [id, message] of [['pack-name-error', nameError], ['pack-prefix-error', prefixError]]) { + get(id).textContent = message; + get(id).hidden = !message; + } + errorMessage = nameError || prefixError || pathError; packValid = !errorMessage; - get('pack-error').textContent = errorMessage; + get('pack-error').textContent = pathError || (errorMessage ? 'Исправьте отмеченные поля, чтобы создать архив.' : ''); get('pack-error').hidden = packValid; + const example = selected()[0]; + get('pack-example-title').textContent = example ? 'Как изменится путь файла' : 'Пример'; + get('pack-example-settings').hidden = Boolean(example); + get('pack-example-source').textContent = example ? example.path : 'images/bg.png'; + get('pack-example-target').textContent = example + ? pathPreviewValid ? validPackPaths.get(example.path) : 'Исправьте настройки пути' + : 'my_mod/images/bg.png'; return packValid; }; const refreshPackFolders = () => { @@ -376,13 +397,17 @@ validatePack(); }; const applyMode = () => { + app.dataset.mode = mode; + get('pack-clear-label').hidden = mode !== 'pack'; + get('pack-notice').hidden = mode !== 'pack'; + get('pack-integration').hidden = mode !== 'pack'; get('mode-pack').checked = mode === 'pack'; get('mode-unpack').checked = mode !== 'pack'; get('pack-settings').hidden = mode !== 'pack'; get('options').hidden = mode === 'pack'; get('pack-help').hidden = mode !== 'pack'; get('unpack-help').hidden = mode === 'pack'; - get('queue-heading').textContent = mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы'; + get('queue-heading').textContent = mode === 'pack' ? 'Файлы в архиве' : 'Добавленные файлы'; list.setAttribute('aria-label', mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы'); get('drop-help').textContent = mode === 'pack' ? 'Изображения, музыка, шрифты и другие файлы' : '.rpa, .rpyc, .rpymc, .pyc'; fileInput.accept = mode === 'pack' ? '' : '.rpa,.rpyc,.rpymc,.pyc'; diff --git a/templates/tools.html b/templates/tools.html index 14d9898..05b8a71 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -14,7 +14,7 @@

Инструменты

Онлайн-инструменты

diff --git a/templates/tools_colors.html b/templates/tools_colors.html index b776a28..1b0f9e4 100644 --- a/templates/tools_colors.html +++ b/templates/tools_colors.html @@ -13,7 +13,7 @@
{% include "partials/footer.html" %} {% endblock %} diff --git a/templates/tools_unpack.html b/templates/tools_unpack.html index 8ae6da0..8acd3f4 100644 --- a/templates/tools_unpack.html +++ b/templates/tools_unpack.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% set page_title = 'Архивы RPA и декомпиляция RPYC/PYC — ES Doc' %} +{% set page_title = 'Архивация RPA и декомпиляция RPYC/PYC — ES Doc' %} {% set body_class = 'bg-solid scene-page' %} {% set active = 'tools' %} {% set with_unpacker_js = true %} @@ -11,7 +11,7 @@
Инструменты -

Архивы RPA и декомпиляция

+

Архивация RPA и декомпиляция

@@ -42,20 +42,43 @@

Файлы Ren'Py

Настройки декомпиляции @@ -73,14 +96,15 @@

Файлы Ren'Py

Добавленные файлы

-
-

+
    diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs index 8bac164..92f6c0a 100644 --- a/tests/rpa_browser.cjs +++ b/tests/rpa_browser.cjs @@ -6,13 +6,13 @@ const path = require('node:path'); const { execFileSync } = require('node:child_process'); const { chromium } = require('playwright'); const root = path.resolve(__dirname, '..'); -const fixtures = path.join(root, 'temp/rpa-browser/game'); +const fixtures = path.join(root, 'temp/rpa-tree-browser/game'); const target = process.env.RPA_TEST_URL || 'http://127.0.0.1:8012/tools/unpack?mode=pack'; const resources = { - 'mods/my_mod/images/bg.png': Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'), - 'mods/my_mod/audio/тема.ogg': Buffer.from([0, 255, 128, 65]), - 'mods/my_mod/empty.txt': Buffer.alloc(0), - 'mods/my_mod/script.rpy': Buffer.from('label my_mod_start:\n return\n'), + 'my_mod/images/bg.png': Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'), + 'my_mod/audio/тема.ogg': Buffer.from([0, 255, 128, 65]), + 'my_mod/empty.txt': Buffer.alloc(0), + 'my_mod/script.rpy': Buffer.from('label my_mod_start:\n return\n'), }; for (const [name, data] of Object.entries(resources)) { const file = path.join(fixtures, name); @@ -39,23 +39,29 @@ for (const [name, data] of Object.entries(resources)) { element.getAttribute('aria-describedby').split(/\s+/).filter(id => !document.getElementById(id)))); assert.deepEqual(missingDescriptions, []); assert.equal(await page.locator('#tools-pack-strip').inputValue(), 'game/'); - assert.match(await page.locator('#tools-file-list').textContent(), /my_mod\/images\/bg.png/); - const verifiedPaths = await page.locator('.tools-pack-path').allTextContents(); + assert(await page.locator('#tools-browser').isVisible()); + await page.locator('#tools-browser-list button').filter({hasText: 'my_mod/'}).click(); + await page.locator('#tools-browser-list button').filter({hasText: 'images/'}).click(); + await page.locator('#tools-browser-list button').filter({hasText: 'bg.png'}).click(); + assert(await page.locator('#tools-browser-preview img').isVisible()); + await page.locator('#tools-browser-search').fill('/'); + const verifiedPaths = await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(); await page.locator('#tools-pack-prefix').fill('../outside'); assert(await page.locator('#tools-download-all').isDisabled()); assert(await page.locator('#tools-pack-error').isVisible()); assert.match(await page.locator('#tools-pack-prefix-error').textContent(), /относительный путь.*my_mod/); assert(await page.locator('#tools-pack-preview-note').isVisible()); - assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths); await page.locator('#tools-pack-prefix').fill(''); await page.locator('#tools-pack-name').fill('my_mod'); await page.locator('#tools-mode-unpack').check(); assert(!await page.locator('#tools-queue').isVisible()); await page.locator('#tools-mode-pack').check(); - assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + await page.locator('#tools-browser-search').fill('/'); + assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths); assert.equal(await page.locator('#tools-pack-name').inputValue(), 'my_mod'); - await page.locator('#tools-queue-search').fill('bg.png'); - assert.equal(await page.locator('.tools-pack-path').count(), 1); + await page.locator('#tools-browser-search').fill('bg.png'); + assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 1); // Cancel lazy startup and retry from the same File objects. await page.locator('#tools-download-all').click(); @@ -70,10 +76,10 @@ for (const [name, data] of Object.entries(resources)) { await saved.saveAs(archive); assert.match(await page.locator('#tools-result-description').textContent(), /Файлов в RPA: 4/); assert.equal(await page.locator('#tools-queue').isVisible(), true); - await page.locator('#tools-queue-search').fill(''); - assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + await page.locator('#tools-browser-search').fill('/'); + assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths); await page.locator('#tools-pack-prefix').fill('alternate'); - assert.equal(await page.locator('.tools-pack-path').first().textContent(), 'alternate/' + verifiedPaths[0]); + assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').first().textContent(), 'alternate/' + verifiedPaths[0]); assert(await page.locator('#tools-download-all').isEnabled()); await page.locator('#tools-pack-prefix').fill(''); const python = path.join(root, '.venv/Scripts/python.exe'); @@ -88,6 +94,11 @@ for (const [name, data] of Object.entries(resources)) { ' assert content == (pathlib.Path(sys.argv[2]) / name).read_bytes(), name', 'assert len(index) == 4', ].join('\n'), archive, fixtures]); + await page.locator('#tools-browser-search').fill('script.rpy'); + await page.locator('#tools-browser-list button[data-entry-id]').click(); + await page.locator('#tools-browser-preview pre .k').first().waitFor({state: 'visible', timeout: 90000}); + assert.match(await page.locator('#tools-browser-preview code').textContent(), /label my_mod_start/); + await page.locator('#tools-browser-search').fill('/'); assert(requests.every(([method]) => method === 'GET'), JSON.stringify(requests)); assert(!requests.some(([, url]) => /vendor\.zip|bytecode\.zip/.test(url)), 'Packing must not load decompilers'); @@ -96,7 +107,8 @@ for (const [name, data] of Object.entries(resources)) { await page.locator('#tools-files').setInputFiles(archive); await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 }); await page.locator('#tools-mode-pack').check(); - assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths); + await page.locator('#tools-browser-search').fill('/'); + assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths); await page.locator('#tools-mode-unpack').check(); await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 }); await page.locator('#tools-browser-search').fill('тема'); @@ -122,22 +134,22 @@ for (const [name, data] of Object.entries(resources)) { await page.locator('#tools-files').setInputFiles(Array.from({ length: 251 }, (_, i) => ({ name: `item-${String(i).padStart(3, '0')}.bin`, mimeType: 'application/octet-stream', buffer: Buffer.from([i % 256]), }))); - assert.equal(await page.locator('.tools-pack-path').count(), 100); + assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 100); await page.locator('#tools-queue-next').click(); await page.locator('#tools-queue-next').click(); - assert.equal(await page.locator('.tools-pack-path').count(), 51); + assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 51); assert.match(await page.locator('#tools-queue-range').textContent(), /201–251 из 251/); - await page.locator('#tools-queue-search').fill('item-250'); - await page.locator('.tools-remove').focus(); + await page.locator('#tools-browser-search').fill('item-250'); + await page.locator('#tools-browser-list .tools-remove-source').focus(); await page.keyboard.press('Enter'); assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); - await page.locator('#tools-queue-search').fill(''); - await page.locator('.tools-remove').first().focus(); + await page.locator('#tools-browser-search').fill(''); + await page.locator('#tools-browser-list .tools-remove-source').first().focus(); await page.keyboard.press('Enter'); - assert(await page.locator('.tools-remove').first().evaluate(el => el === document.activeElement)); + assert(await page.locator('#tools-browser-list .tools-remove-source').first().evaluate(el => el === document.activeElement)); await page.locator('#tools-clear').click(); await page.locator('#tools-files').setInputFiles({ name: 'last.txt', mimeType: 'text/plain', buffer: Buffer.from('last') }); - await page.locator('.tools-remove').focus(); + await page.locator('#tools-browser-list .tools-remove-source').focus(); await page.keyboard.press('Enter'); assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs index 08e3543..65cfad6 100644 --- a/tests/tools_ui.test.mjs +++ b/tests/tools_ui.test.mjs @@ -25,7 +25,7 @@ function page(search = '', withClipboard = false) { const matches = []; const visit = element => { for (const child of element.children) { - if (selector === '.tools-remove' && child.className === 'tools-remove') matches.push(child); + if ((selector === '.tools-remove' && child.className === 'tools-remove') || (selector === '.tools-remove-source' && child.className?.includes('tools-remove-source'))) matches.push(child); if (selector === 'button' && child.tagName === 'BUTTON') matches.push(child); visit(child); } @@ -116,8 +116,7 @@ test('packing accepts resources and sends paths relative to game without catalog await Promise.resolve(); assert.equal(ui.messages.length, 0); assert.equal(ui.get('pack-strip').value, 'game/'); - assert.equal(ui.get('file-list').children[0].children[0].children[0].children[0].textContent, - 'mods/demo/images/bg.png'); + assert.equal(ui.get('browser-list').children[0].children[0].children[1].textContent, 'mods/'); ui.get('download-all').click(); assert.equal(ui.messages.at(-1).type, 'pack'); assert.equal(ui.messages.at(-1).name, 'demo.rpa'); @@ -135,6 +134,61 @@ test('packing accepts resources and sends paths relative to game without catalog assert.equal(ui.messages.at(-1).files[0].path, 'next/mods/demo/images/bg.png'); }); +test('packing browses folders and previews original resources without decompilation', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-prefix').value = 'my_mod/images'; + await ui.add(['bg.png']); + assert.equal(ui.get('browser').hidden, false); + assert.equal(ui.get('file-list').hidden, true); + const first = () => ui.get('browser-list').children[0].children[0]; + assert.equal(first().children[1].textContent, 'my_mod/'); + first().click(); + assert.equal(first().children[1].textContent, 'images/'); + first().click(); + assert.equal(first().children[1].textContent, 'bg.png'); + first().click(); + assert.equal(ui.get('browser-preview').children[1].tagName, 'IMG'); + assert.equal(ui.messages.length, 0); +}); + +test('packing text previews request syntax highlighting of the original file', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + await ui.add(['script.rpy']); + ui.get('browser-list').children[0].children[0].click(); + assert.equal(ui.messages.at(-1).type, 'pack-preview'); + assert.equal(ui.messages.at(-1).name, 'script.rpy'); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, + name: 'script.rpy', buffer: new TextEncoder().encode('label start:\n return').buffer, + html: 'label start:', warnings: [] }); + assert.equal(ui.get('browser-preview').attributes['aria-busy'], 'false'); + assert.equal(ui.get('download-all').disabled, false); +}); + +test('removing the last packing file returns keyboard focus to adding files', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + await ui.add(['last.txt']); + const remove = ui.get('browser-list').querySelectorAll('.tools-remove-source')[0]; + remove.focus(); remove.click(); + assert.equal(ui.get('browser').hidden, true); + assert.equal(ui.document.activeElement, ui.get('add')); +}); + +test('packing previews keep compiled files intact and do not start decompilers', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + await ui.add(['script.rpyc']); + ui.get('browser-list').children[0].children[0].click(); + assert.equal(ui.messages.length, 0); + const preview = ui.get('browser-preview'); + assert.equal(preview.children.at(-1).download, 'script.rpyc'); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).type, 'pack'); + assert.equal(ui.messages.at(-1).files[0].path, 'script.rpyc'); +}); + test('mode switches retain separate queues and packing settings', async () => { const ui = page('?mode=pack'); ui.get('pack-name').value = 'demo'; @@ -160,26 +214,26 @@ test('packing pagination and search reach all files without filtering export', a ui.get('pack-name').value = 'demo'; ui.get('pack-name').emit('input'); await ui.add(Array.from({ length: 251 }, (_, i) => `item-${String(i).padStart(3, '0')}.bin`)); - const rows = () => ui.get('file-list').children[0].children; + const rows = () => ui.get('browser-list').children; assert.equal(rows().length, 100); ui.get('queue-next').click(); ui.get('queue-next').click(); assert.equal(rows().length, 51); assert.equal(ui.get('queue-range').textContent, '201–251 из 251'); - ui.get('queue-search').value = 'item-250'; - ui.get('queue-search').emit('input'); + ui.get('browser-search').value = 'item-250'; + ui.get('browser-search').emit('input'); assert.equal(rows().length, 1); ui.get('download-all').click(); assert.equal(ui.messages.at(-1).files.length, 251); ui.exportDone(['demo.rpa']); - const remove = ui.get('file-list').querySelectorAll('.tools-remove')[0]; + const remove = ui.get('browser-list').querySelectorAll('.tools-remove-source')[0]; remove.focus(); remove.click(); assert.equal(ui.document.activeElement, ui.get('add')); - ui.get('queue-search').value = ''; - ui.get('queue-search').emit('input'); - const first = ui.get('file-list').querySelectorAll('.tools-remove')[0]; + ui.get('browser-search').value = ''; + ui.get('browser-search').emit('input'); + const first = ui.get('browser-list').querySelectorAll('.tools-remove-source')[0]; first.focus(); first.click(); - assert.equal(ui.document.activeElement, ui.get('file-list').querySelectorAll('.tools-remove')[0]); + assert.equal(ui.document.activeElement, ui.get('browser-list').querySelectorAll('.tools-remove-source')[0]); ui.get('clear').click(); assert.equal(ui.get('queue').hidden, true); assert.equal(ui.document.activeElement, ui.get('add')); @@ -191,7 +245,9 @@ test('an invalid prefix preserves the verified path preview and explains how to ui.get('pack-prefix').value = 'mods/demo'; ui.get('pack-prefix').emit('input'); await ui.add(['bg.png']); - const path = () => ui.get('file-list').children[0].children[0].children[0].children[0].textContent; + ui.get('browser-search').value = 'bg.png'; + ui.get('browser-search').emit('input'); + const path = () => ui.get('browser-list').children[0].children[0].children[1].textContent; assert.equal(path(), 'mods/demo/bg.png'); ui.get('pack-prefix').value = '../bad'; ui.get('pack-prefix').emit('input'); From 281f8eea2a2322ca6c8143aac99f22e5bf4aeb96 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:07:36 +0300 Subject: [PATCH 05/27] feature: added ability to remove folders while packing rpa --- static/js/tools.js | 55 ++++++++++++++++++++++++----------------- tests/rpa_browser.cjs | 20 ++++++++++++++- tests/tools_ui.test.mjs | 38 ++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/static/js/tools.js b/static/js/tools.js index e6330de..04a7406 100644 --- a/static/js/tools.js +++ b/static/js/tools.js @@ -549,8 +549,40 @@ const visibleFolders = mode === 'pack' ? folderNames.slice(offset, offset + pageSize) : folderNames; const visibleEntries = entries.slice(mode === 'pack' ? Math.max(0, offset - folderNames.length) : 0, mode === 'pack' ? Math.max(0, offset + pageSize - folderNames.length) : 500); + const addRemove = (originalPaths, label, title) => { + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'tools-icon-button tools-remove-source'; + remove.title = title; + remove.setAttribute('aria-label', label); + remove.innerHTML = ''; + remove.disabled = busy; + remove.addEventListener('click', () => { + if (busy || enumerating) return; + const focused = document.activeElement === remove; + const index = Array.from(browserList.querySelectorAll('.tools-remove-source')).indexOf(remove); + for (const originalPath of originalPaths) { + files.delete(originalPath); + if (mode === 'pack') validPackPaths.delete(originalPath); + } + clearResult(); + if (mode === 'pack') refreshPackFolders(); + render(); + loadCatalog(); + if (focused && mode === 'pack') { + const buttons = files.size ? browserList.querySelectorAll('.tools-remove-source') : []; + (buttons[Math.min(index, buttons.length - 1)] || addButton).focus(); + } + }); + browserList.lastElementChild.appendChild(remove); + }; for (const folder of visibleFolders) { row(folder + '/', 'папка', () => { browseFolder += folder + '/'; queuePage = 0; renderBrowse(); }, 'folder'); + if (mode === 'pack') { + const folderPath = browseFolder + folder + '/'; + const originalPaths = browseEntries.filter(entry => entry.path.startsWith(folderPath)).map(entry => entry.originalPath); + addRemove(originalPaths, 'Убрать папку ' + folderPath + ' и все вложенные файлы', 'Убрать папку и все вложенные файлы'); + } } for (const entry of visibleEntries) { const kind = /\.(rpyc|rpymc|pyc|rpy|rpym|py|js|json|css|txt)$/i.test(entry.path) ? 'code' @@ -559,28 +591,7 @@ row(query ? entry.path : entry.path.slice(browseFolder.length), size(entry.size), () => readBrowse(entry), kind, selectedEntry?.id === entry.id, entry.id); if (mode === 'pack' || catalogFiles[entry.source]?.path === entry.path && !isArchive(catalogFiles[entry.source]) && files.has(entry.path)) { - const remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'tools-icon-button tools-remove-source'; - remove.title = 'Убрать файл'; - remove.setAttribute('aria-label', 'Убрать ' + entry.path); - remove.innerHTML = ''; - remove.disabled = busy; - remove.addEventListener('click', () => { - const focused = document.activeElement === remove; - const index = Array.from(browserList.querySelectorAll('.tools-remove-source')).indexOf(remove); - files.delete(mode === 'pack' ? entry.originalPath : entry.path); - if (mode === 'pack') validPackPaths.delete(entry.originalPath); - clearResult(); - if (mode === 'pack') refreshPackFolders(); - render(); - loadCatalog(); - if (focused && mode === 'pack') { - const buttons = files.size ? browserList.querySelectorAll('.tools-remove-source') : []; - (buttons[Math.min(index, buttons.length - 1)] || addButton).focus(); - } - }); - browserList.lastElementChild.appendChild(remove); + addRemove([mode === 'pack' ? entry.originalPath : entry.path], 'Убрать ' + entry.path, 'Убрать файл'); } } if (!folders.size && !entries.length) { diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs index 92f6c0a..d842f26 100644 --- a/tests/rpa_browser.cjs +++ b/tests/rpa_browser.cjs @@ -128,9 +128,27 @@ for (const [name, data] of Object.entries(resources)) { ].join('\n'), zipPath, fixtures]); assert.deepEqual(errors, []); - // Reach a file beyond the former 200-row limit, then remove it by keyboard. + // Remove a folder by keyboard, including its descendants, then the remaining root. await page.locator('#tools-mode-pack').check(); await page.locator('#tools-clear').click(); + await page.locator('#tools-folder').setInputFiles(fixtures); + await page.locator('#tools-browser-search').fill(''); + await page.locator('#tools-browser-list button').filter({hasText: 'my_mod/'}).click(); + const removeImages = page.getByRole('button', { name: 'Убрать папку my_mod/images/ и все вложенные файлы', exact: true }); + await removeImages.focus(); + await page.keyboard.press('Enter'); + assert.match(await page.locator('#tools-count').textContent(), /3 файла/); + assert.equal(await page.locator('#tools-browser-list button').filter({hasText: 'images/'}).count(), 0); + assert.equal(await page.locator('#tools-browser-list button').filter({hasText: 'audio/'}).count(), 1); + assert(await page.locator('#tools-browser-list .tools-remove-source').nth(1).evaluate(el => el === document.activeElement)); + await page.locator('#tools-browser-crumbs button').filter({hasText: 'Все файлы'}).click(); + await page.getByRole('button', { name: 'Убрать папку my_mod/ и все вложенные файлы', exact: true }).focus(); + await page.keyboard.press('Enter'); + assert(!await page.locator('#tools-browser').isVisible()); + assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); + assert(await page.locator('#tools-download-all').isDisabled()); + + // Reach a file beyond the former 200-row limit, then remove it by keyboard. await page.locator('#tools-files').setInputFiles(Array.from({ length: 251 }, (_, i) => ({ name: `item-${String(i).padStart(3, '0')}.bin`, mimeType: 'application/octet-stream', buffer: Buffer.from([i % 256]), }))); diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs index 65cfad6..f8e68d0 100644 --- a/tests/tools_ui.test.mjs +++ b/tests/tools_ui.test.mjs @@ -176,6 +176,44 @@ test('removing the last packing file returns keyboard focus to adding files', as assert.equal(ui.document.activeElement, ui.get('add')); }); +test('removing a packing folder drops all descendants using their original paths', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-prefix').value = 'bundle'; + ui.get('folder').files = ['images/bg.png', 'images/nested/icon.png', 'images2/keep.png'].map(path => + ({ name: path.split('/').at(-1), webkitRelativePath: 'game/my_mod/' + path, size: 1 })); + ui.get('folder').emit('change'); + await Promise.resolve(); + ui.get('browser-list').children[0].children[0].click(); + ui.get('browser-list').children[0].children[0].click(); + const folder = ui.get('browser-list').children[0]; + assert.equal(folder.children[0].children[1].textContent, 'images/'); + const remove = folder.children[2]; + assert.ok(remove, 'folder has a remove button'); + assert.match(remove.attributes['aria-label'], /вложенные файлы/); + remove.focus(); remove.click(); + assert.match(ui.get('count').textContent, /1 файл/); + assert.equal(ui.get('browser-list').children[0].children[0].children[1].textContent, 'images2/'); + assert.equal(ui.document.activeElement, ui.get('browser-list').children[0].children[2]); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).files.length, 1); + assert.equal(ui.messages.at(-1).files[0].path, 'bundle/my_mod/images2/keep.png'); +}); + +test('removing the root packing folder clears the queue and returns focus to adding', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('pack-prefix').value = 'my_mod'; + await ui.add(['bg.png', 'theme.ogg']); + const remove = ui.get('browser-list').children[0].children[2]; + assert.ok(remove, 'root folder has a remove button'); + remove.focus(); remove.click(); + assert.equal(ui.get('queue').hidden, true); + assert.equal(ui.get('browser').hidden, true); + assert.equal(ui.document.activeElement, ui.get('add')); + assert.equal(ui.get('download-all').disabled, true); +}); + test('packing previews keep compiled files intact and do not start decompilers', async () => { const ui = page('?mode=pack'); ui.get('pack-name').value = 'demo'; From 8a5c3a383d4819a224374a9f238057c4c1b783bd Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:35:00 +0300 Subject: [PATCH 06/27] feature: staged disclosure workflow added for tools --- static/css/tools.css | 22 +++++ static/js/tools.js | 163 +++++++++++++++++++++++++++++------- templates/tools_unpack.html | 51 ++++++----- tests/rpa_browser.cjs | 94 ++++++++++++++++++--- tests/tools_ui.test.mjs | 141 +++++++++++++++++++++++++++++-- 5 files changed, 407 insertions(+), 64 deletions(-) diff --git a/static/css/tools.css b/static/css/tools.css index 9c47cad..1bb5370 100644 --- a/static/css/tools.css +++ b/static/css/tools.css @@ -38,6 +38,25 @@ .tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; } .tools-drop-title { margin: 0 0 6px; font-size: var(--fs-body); font-weight: 600; } .tools-drop p:not(.tools-drop-title) { color: var(--text-soft); font-size: var(--fs-ui); } +.tools-drop.is-compact { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; padding: 12px 16px; text-align: start; } +.tools-drop.is-compact .tools-drop-icon { display: none; } +.tools-drop.is-compact .tools-drop-title { margin: 0; color: var(--text-soft); font-size: var(--fs-ui); font-weight: 400; } +.tools-drop.is-compact .tools-button { flex: none; } +.tools-export-dialog { width: min(42rem, calc(100% - 32px)); max-height: calc(100dvh - 32px); box-sizing: border-box; padding: 24px; border: 0; border-radius: var(--radius-block); background: var(--bg-elevated); color: var(--text); box-shadow: var(--shadow-modal); overflow-y: auto; scrollbar-color: var(--border) var(--bg-elevated); } +.tools-export-dialog::backdrop { background: var(--scrim); } +html:has(.tools-export-dialog[open]) { overflow: hidden; } +.tools-dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.tools-dialog-heading h2 { margin: 0; font-family: var(--font-serif); font-size: var(--fs-subtitle); } +.tools-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; } +.tools-dialog-close:hover { color: var(--text); background: var(--bg-light); } +.tools-export-dialog .tools-status { margin: 0 0 16px; } +.tools-export-dialog .tools-status:empty { display: none; } +.tools-export-dialog .tools-result-meta { margin-block: 16px; font-size: var(--fs-ui); } +.tools-dialog-downloads { display: flex; align-items: flex-start; flex-direction: column; gap: 12px; margin-top: 20px; } +.tools-dialog-downloads:not(:has(> :not([hidden]))) { display: none; } +.tools-dialog-downloads .tools-meta { margin: 0; overflow-wrap: anywhere; } +.tools-export-dialog .tools-warnings[open] > ul { position: static; width: auto; max-width: 100%; margin-top: 12px; } +.tools-export-dialog .tools-pack-integration { padding-top: 24px; border-top: 1px solid var(--border); } .tools-button { display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 10px 18px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); font-weight: 600; cursor: pointer; text-decoration: none; } .tools-button:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } .tools-button-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); } @@ -199,6 +218,9 @@ .tools-page [hidden] { display: none !important; } @media (max-width: 640px) { .tools-drop { padding: 24px 14px 20px; } + .tools-drop.is-compact { flex-direction: column; align-items: stretch; padding: 12px 14px; text-align: center; } + .tools-export-dialog { padding: 20px; } + .tools-dialog-downloads > .tools-button, .tools-dialog-downloads .tools-downloads, .tools-dialog-downloads .tools-downloads > a { width: 100%; } .tools-link-list a { grid-template-columns: 1fr auto; gap: 4px 12px; } .tools-link-list a span:nth-child(2) { grid-column: 1; } .tools-link-list a span:last-child { grid-column: 2; grid-row: 1 / 3; } diff --git a/static/js/tools.js b/static/js/tools.js index 04a7406..60a4e6e 100644 --- a/static/js/tools.js +++ b/static/js/tools.js @@ -13,6 +13,9 @@ const queue = get('queue'), list = get('file-list'), progress = get('progress'); const progressBar = get('progress-bar'), current = get('current'); const resultPanel = get('result'), downloads = get('downloads'), downloadAll = get('download-all'); + const exportDialog = get('export-dialog'), exportStart = get('export-start'); + const exportStatus = get('export-status'), exportErrors = get('export-errors'); + const downloadButton = 'Скачать'; const warningsPanel = get('warnings'), warningList = get('warning-list'); const browseActions = get('browse-actions'), browseArchive = get('archive'); const browser = get('browser'), browserList = get('browser-list'); @@ -39,6 +42,7 @@ let browseEntries = [], browseFolder = '', browseRequest = 0, previewUrl = null; let catalogEntries = [], catalogFiles = [], catalogRequest = 0, selectedEntry = null; let task = '', exportReady = false, catalogSubmitted = false, previewFont = null, clearOnDownload = false; + let packDownloadStarted = false, exportFinished = false; let operation = 0; let previewPending = null, pendingPreviewKey = ''; const previewCache = new Map(); @@ -59,13 +63,28 @@ previewCacheBytes += bytes; }; - const say = (message, error = false) => { - status.textContent = message; - status.classList.toggle('is-error', error); - errorsPanel.hidden = true; - errorsPanel.replaceChildren(); + const say = (message, error = false, inExport = task === 'export') => { + const target = inExport ? exportStatus : status; + const diagnostics = inExport ? exportErrors : errorsPanel; + target.textContent = message; + target.classList.toggle('is-error', error); + diagnostics.hidden = true; + diagnostics.replaceChildren(); + }; + const notifyDownload = () => { + exportStatus.textContent = 'Скачивание запущено.'; + exportStatus.classList.remove('is-error'); }; const clearResult = () => { + exportFinished = false; + if (exportDialog.open) exportDialog.close(); + task = ''; + exportStatus.textContent = ''; + exportErrors.hidden = true; + exportErrors.replaceChildren(); + get('export-summary').hidden = true; + packDownloadStarted = false; + get('pack-integration').hidden = true; previewCache.clear(); previewCacheBytes = 0; pendingPreviewKey = ''; @@ -91,6 +110,8 @@ outputs.length = 0; chunks = []; downloads.replaceChildren(); + downloads.hidden = true; + get('export-info').hidden = true; downloadAll.hidden = true; resultPanel.hidden = true; get('result-description').textContent = ''; @@ -118,6 +139,19 @@ const isArchive = entry => /\.rpa$/i.test(entry.path); const updateControls = () => { const locked = busy || enumerating || !supported; + const hasFiles = files.size > 0; + exportStart.hidden = !hasFiles && !exportFinished; + exportStart.disabled = locked || (!exportFinished && mode === 'pack' && !packValid); + exportStart.innerHTML = exportFinished ? 'Открыть результат' : downloadButton; + get('export-close').setAttribute('aria-label', busy && task === 'export' ? 'Отменить обработку и закрыть окно' : 'Закрыть окно'); + get('pack-settings').hidden = mode !== 'pack' || !hasFiles; + get('options').hidden = mode === 'pack' || !hasFiles; + get('pack-integration').hidden = mode !== 'pack' || !packDownloadStarted; + drop.classList.toggle('is-compact', hasFiles); + get('drop-title').textContent = hasFiles ? 'Перетащите сюда ещё файлы или папку' : 'Перетащите файлы или папку сюда'; + get('drop-help').hidden = hasFiles; + drop.setAttribute('aria-describedby', hasFiles ? 'tools-drop-title' : 'tools-drop-help'); + get('add-label').textContent = hasFiles ? 'Добавить ещё' : 'Добавить файлы или папку'; start.disabled = locked || !selected().length || (mode === 'pack' && !packValid); start.hidden = mode === 'pack' || !retryAvailable || locked || !selected().length; addButton.disabled = locked; @@ -129,7 +163,8 @@ queuePrevious.disabled = locked || queuePage === 0; queueNext.disabled = locked || (queuePage + 1) * queuePageSize >= queueMatches; cancel.hidden = !busy && !enumerating; - const cancelHost = busy && task === 'preview' && previewPending ? previewPending : operationActions; + const cancelHost = busy && task === 'export' ? get('export-actions') + : busy && task === 'preview' && previewPending ? previewPending : operationActions; if (cancel.parentNode !== cancelHost) cancelHost.appendChild(cancel); downloadAll.disabled = locked || (!selected().length && !outputs.length) || (mode === 'pack' && !packValid); browseArchive.disabled = locked; @@ -311,9 +346,12 @@ const showOutputs = () => { resultPanel.hidden = false; start.classList.remove('tools-button-primary'); - downloadAll.hidden = false; - downloadAll.innerHTML = '' - + (exportReady ? 'Скачать ещё раз' : mode === 'pack' ? 'Создать и скачать RPA' : 'Скачать всё в ZIP') + ''; + const singleResult = exportReady && outputs.length === 1; + downloadAll.hidden = exportReady && outputs.length > 1; + downloadAll.innerHTML = exportFinished && !exportReady ? 'Повторить обработку' : downloadButton; + downloads.hidden = !outputs.length || singleResult; + get('export-info').hidden = !singleResult; + if (singleResult) get('export-info').textContent = `${outputs[0].name} / ${size(outputs[0].blob.size)}`; if (outputs.length === 1 && mode !== 'pack') outputs[0].link.classList.add('tools-button-primary'); }; const options = () => ({ try_harder: get('try-harder').checked, no_init_offset: get('no-init-offset').checked }); @@ -393,11 +431,8 @@ get('pack-clear-label').hidden = mode !== 'pack'; get('pack-notice').hidden = mode !== 'pack'; get('queue-tools').hidden = mode !== 'pack'; - get('pack-integration').hidden = mode !== 'pack'; get('mode-pack').checked = mode === 'pack'; get('mode-unpack').checked = mode !== 'pack'; - get('pack-settings').hidden = mode !== 'pack'; - get('options').hidden = mode === 'pack'; get('pack-help').hidden = mode !== 'pack'; get('unpack-help').hidden = mode === 'pack'; get('queue-heading').textContent = mode === 'pack' ? 'Файлы в архиве' : 'Добавленные файлы'; @@ -803,7 +838,7 @@ empty.textContent = 'Файл не выбран'; browserPreview.appendChild(empty); browser.hidden = false; - get('browser-title').textContent = mode === 'pack' ? 'Пути внутри архива' : scripts ? 'Сценарии' : catalogFiles[source]?.path || 'Файлы'; + get('browser-title').textContent = mode === 'pack' ? 'Пути внутри архива' : scripts ? 'Скрипты' : catalogFiles[source]?.path || 'Файлы'; renderBrowse(); }; on(browseArchive, 'change', openBrowse); @@ -835,16 +870,17 @@ block.append(title, explanation, details); return block; }; - const showErrors = messages => { + const showErrors = (messages, inExport = task === 'export') => { const errors = messages.slice(0, 100).map(message => explainError(message)); + const diagnostics = inExport ? exportErrors : errorsPanel; say(messages.length === 1 ? errors[0].title + (errors[0].path ? `: ${errors[0].path}` : '') + '.' - : `Не удалось обработать файлов: ${messages.length.toLocaleString('ru-RU')}. Причины указаны ниже.`, true); - errorsPanel.hidden = false; - for (const error of errors) errorsPanel.appendChild(errorDiagnostic(error)); + : `Не удалось обработать файлов: ${messages.length.toLocaleString('ru-RU')}. Причины указаны ниже.`, true, inExport); + diagnostics.hidden = false; + for (const error of errors) diagnostics.appendChild(errorDiagnostic(error)); if (messages.length > 100) { const remainder = document.createElement('p'); remainder.textContent = `Показаны причины первых 100 ошибок из ${messages.length.toLocaleString('ru-RU')}. Обрабатывайте файлы меньшими наборами, чтобы увидеть остальные.`; - errorsPanel.appendChild(remainder); + diagnostics.appendChild(remainder); } }; const endPreview = message => { @@ -872,20 +908,26 @@ }; const fail = message => { const interrupted = task; + if (interrupted === 'export') { + exportFinished = true; + get('export-title').textContent = 'Не удалось обработать файлы'; + } task = ''; catalogRequest++; browseRequest++; chunks = []; resetWorker(); - retryAvailable = true; + retryAvailable = interrupted !== 'export'; finish(); - if (outputs.length) { + if (outputs.length && interrupted === 'export') { showOutputs(); + get('export-summary').hidden = false; get('result-description').textContent = `Готовых архивов: ${outputs.length}.`; } if (interrupted === 'preview' || interrupted === 'catalog') endPreview('Просмотр прерван. Повторите действие или выберите другой файл.'); browserStatus.textContent = ''; - showErrors([message]); + showErrors([message], interrupted === 'export'); + if (interrupted === 'export') showOutputs(); }; on(cancel, 'click', () => { const interrupted = task; @@ -896,7 +938,18 @@ catalogRequest++; browseRequest++; chunks = []; - retryAvailable = true; + retryAvailable = interrupted !== 'export'; + if (interrupted === 'export') { + exportFinished = outputs.length > 0; + exportReady = false; + get('export-title').textContent = 'Обработка отменена'; + if (exportFinished) { + get('export-summary').hidden = false; + get('result-description').textContent = `Готовых архивов: ${outputs.length}.`; + showOutputs(); + } + if (exportDialog.open) exportDialog.close(); + } finish(); render(); if (interrupted === 'preview' || interrupted === 'catalog') endPreview('Просмотр отменён. Выберите файл, чтобы открыть его ещё раз.'); @@ -971,11 +1024,22 @@ link.className = 'tools-button'; link.href = url; link.download = data.name; - link.textContent = `${data.name} / ${size(blob.size)}`; + link.innerHTML = ''; + const label = document.createElement('span'); + label.textContent = 'Скачать ' + data.name; + link.appendChild(label); + link.title = `${data.name} / ${size(blob.size)}`; // Downloads do not expose a completion event to the page. // Clear source selections when the user starts a download; // keep result URLs available for retries and remaining ZIPs. on(link, 'click', () => { + if (mode === 'pack' && exportReady) { + packDownloadStarted = true; + get('pack-install-archive').textContent = 'game/my_mod/' + data.name; + get('pack-install-string').textContent = JSON.stringify('my_mod/' + data.name.slice(0, -4)); + updateControls(); + } + notifyDownload(); if (!clearOnDownload) return; for (const entry of activeFiles) files.delete(entry.path); activeFiles = []; @@ -990,10 +1054,14 @@ } else if (data.type === 'fatal') fail(data.error); else if (data.type === 'done') { const result = data.result; + exportFinished = true; + get('export-summary').hidden = false; + get('export-title').textContent = result.failed || !outputs.length + ? outputs.length ? 'Готово с ошибками' : 'Не удалось обработать файлы' : 'Готово'; chunks = []; retryAvailable = false; clearOnDownload = result.failed === 0 && mode !== 'pack'; - exportReady = result.failed === 0; + exportReady = result.failed === 0 && outputs.length > 0; finish(); if (mode === 'pack') render(); resultPanel.hidden = false; @@ -1005,18 +1073,43 @@ renderWarnings(warningsPanel, get('warnings-summary'), warningList, result.warnings, result.warning_details || []); if (result.failed) showErrors(result.errors.length ? result.errors : ['Нет готовых файлов.']); - else say('Готово.'); + else say(''); for (const output of outputs) output.link.click(); } else { get('result-description').textContent = 'Файлы не удалось обработать.'; showErrors(result.errors.length ? result.errors : ['Нет готовых файлов.']); + showOutputs(); } + task = ''; } }; on(downloadAll, 'click', () => { if (exportReady) for (const output of outputs) output.link.click(); else processFiles(); }); + const openExportDialog = () => { + closeMenu(); + if (!exportDialog.open) exportDialog.showModal(); + get('export-close').focus(); + }; + const closeExportDialog = () => { + if (busy && task === 'export') cancel.click(); + if (exportDialog.open) exportDialog.close(); + }; + on(exportStart, 'click', () => { + if (busy || enumerating) return; + if (exportFinished) openExportDialog(); + else processFiles(); + }); + on(get('export-close'), 'click', closeExportDialog); + on(exportDialog, 'cancel', event => { event.preventDefault(); closeExportDialog(); }); + on(exportDialog, 'click', event => { + if (event.target !== exportDialog) return; + const bounds = exportDialog.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right + || event.clientY < bounds.top || event.clientY > bounds.bottom) closeExportDialog(); + }); + on(exportDialog, 'close', () => { if (!disposed) (exportStart.hidden ? addButton : exportStart).focus(); }); const ensureWorker = () => { if (worker) return; worker = new Worker(config.worker, { type: 'module', name: 'esdoc-renpy-tools' }); @@ -1055,7 +1148,7 @@ if (scripts.length) { const option = document.createElement('option'); option.value = 'scripts'; - option.textContent = 'Сценарии'; + option.textContent = 'Скрипты'; browseArchive.appendChild(option); } for (const { source, entry } of archives) { @@ -1069,7 +1162,7 @@ get('archive-label').hidden = browseActions.hidden; resultPanel.hidden = false; downloadAll.hidden = false; - downloadAll.innerHTML = 'Скачать всё в ZIP'; + downloadAll.innerHTML = downloadButton; openBrowse(); if (!archives.length) { updateControls(); @@ -1091,9 +1184,18 @@ for (const output of outputs) URL.revokeObjectURL(output.url); outputs.length = 0; downloads.replaceChildren(); + downloads.hidden = true; + get('export-info').hidden = true; warningsPanel.hidden = true; + downloadAll.hidden = true; chunks = []; exportReady = false; + exportFinished = false; + get('export-summary').hidden = true; + get('export-title').textContent = mode === 'pack' ? 'Упаковка в RPA' : 'Обработка файлов'; + status.textContent = ''; + errorsPanel.hidden = true; + packDownloadStarted = false; clearOnDownload = false; retryAvailable = false; busy = true; @@ -1104,6 +1206,7 @@ progressBar.removeAttribute('value'); current.textContent = ''; say('Подготовка к обработке…'); + openExportDialog(); try { ensureWorker(); if (mode === 'pack') worker.postMessage({ type: 'pack', config, name: archiveName(packName.value), @@ -1116,10 +1219,14 @@ for (const output of outputs) URL.revokeObjectURL(output.url); outputs.length = 0; downloads.replaceChildren(); + downloads.hidden = true; + get('export-info').hidden = true; exportReady = false; + exportFinished = false; + if (exportDialog.open) exportDialog.close(); downloadAll.hidden = !selected().length; updateControls(); - if (selected().length) downloadAll.innerHTML = 'Скачать всё в ZIP'; + if (selected().length) downloadAll.innerHTML = downloadButton; if (option === get('try-harder')) { catalogSubmitted = false; if (selected().some(isArchive)) { diff --git a/templates/tools_unpack.html b/templates/tools_unpack.html index 2d3f23c..5327bf9 100644 --- a/templates/tools_unpack.html +++ b/templates/tools_unpack.html @@ -31,9 +31,9 @@

    Файлы Ren'Py

    {{ lucide_icon('folder-up', 32, 'tools-drop-icon') }} -

    Перетащите файлы или папку сюда

    +

    Перетащите файлы или папку сюда

    .rpa, .rpyc, .rpymc, .pyc

    - +

    Пример

    -

    Выбранная папка: "images"

    -

    Удалить из списка путей: Ничего не удалять

    -

    Добавить папку в начале: my_mod.

    +

    Пример для файла images/bg.png с добавлением папки my_mod.

    На устройстве
    images/bg.png
    В архиве
    my_mod/images/bg.png
    @@ -80,7 +78,7 @@

    Файлы Ren'Py

    -
    +

    Подробнее о подключении RPA в БЛ

    +
    Подробнее
    -

    Предпросмотр обрабатывает только выбранный файл. Декомпилированные скрипты сохраняются в unrpyc.zip, для каждого .rpa создаётся отдельный ZIP с папками и файлами архива. Сценарии .rpyc/.rpymc внутри него восстанавливаются в .rpy/.rpym, поддерживаемые .pyc в .py. Если исходник уже есть, он сохраняется без замены.

    +

    Предпросмотр обрабатывает только выбранный файл. Декомпилированные скрипты сохраняются в unrpyc.zip, для каждого .rpa создаётся отдельный ZIP с папками и файлами архива. Скрипты .rpyc/.rpymc внутри него восстанавливаются в .rpy/.rpym, поддерживаемые .pyc в .py. Если исходник уже есть, он сохраняется без замены.

    UnRpyc восстанавливает .rpyc и .rpymc. Файлы Ren'Py 6/7 тоже обрабатываются, но совместимость зависит от версии игры. Комментарии и исходное форматирование в скомпилированных файлах не сохраняются.

    Расширенная декомпиляция добавляет извлечённые блоки в конец скрипта для изучения и адаптации с сохранением исходных контейнеров. Если Python-блок содержит только байткод, вместо него остаются комментарий и pass с предупреждением.

    uncompyle6 восстанавливает .pyc с байткодом Python 2.0–3.8. Версии 3.9 и новее не поддерживаются. Для файлов из __pycache__ исходник получает обычное имя .py. Комментарии и исходное форматирование не восстанавливаются.

    diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs index d842f26..3e6536b 100644 --- a/tests/rpa_browser.cjs +++ b/tests/rpa_browser.cjs @@ -29,10 +29,13 @@ for (const [name, data] of Object.entries(resources)) { page.on('pageerror', error => errors.push(error.message)); context.on('request', request => requests.push([request.method(), request.url()])); await page.goto(target); - await page.locator('#tools-pack-settings').waitFor({ state: 'visible' }); + await page.locator('#tools-controls').waitFor({ state: 'visible' }); + assert(!await page.locator('#tools-pack-settings').isVisible()); + assert(!await page.locator('#tools-pack-integration').isVisible()); assert(await page.locator('#tools-mode-pack').isChecked()); await page.locator('#tools-folder').setInputFiles(fixtures); - await page.locator('#tools-download-all').waitFor({ state: 'visible' }); + await page.locator('#tools-export-start').waitFor({ state: 'visible' }); + assert(await page.locator('#tools-pack-settings').isVisible()); assert(await page.locator('#tools-add').isVisible()); const missingDescriptions = await page.locator('#tools-app [aria-describedby]').evaluateAll(elements => elements.filter(element => element.getClientRects().length).flatMap(element => @@ -47,7 +50,7 @@ for (const [name, data] of Object.entries(resources)) { await page.locator('#tools-browser-search').fill('/'); const verifiedPaths = await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(); await page.locator('#tools-pack-prefix').fill('../outside'); - assert(await page.locator('#tools-download-all').isDisabled()); + assert(await page.locator('#tools-export-start').isDisabled()); assert(await page.locator('#tools-pack-error').isVisible()); assert.match(await page.locator('#tools-pack-prefix-error').textContent(), /относительный путь.*my_mod/); assert(await page.locator('#tools-pack-preview-note').isVisible()); @@ -64,23 +67,55 @@ for (const [name, data] of Object.entries(resources)) { assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 1); // Cancel lazy startup and retry from the same File objects. - await page.locator('#tools-download-all').click(); - await page.locator('#tools-cancel').click(); + await page.locator('#tools-export-start').click(); + assert(await page.locator('#tools-export-dialog').isVisible()); + assert(await page.locator('#tools-export-actions #tools-cancel').isVisible()); + await page.keyboard.press('Escape'); + assert(!await page.locator('#tools-export-dialog').isVisible()); assert.match(await page.locator('#tools-status').textContent(), /отменена/); assert(await page.locator('#tools-queue').isVisible()); const download = page.waitForEvent('download', { timeout: 90000 }); - await page.locator('#tools-download-all').click(); + await page.locator('#tools-export-start').click(); const saved = await download; assert.equal(saved.suggestedFilename(), 'my_mod.rpa'); const archive = path.join(root, 'temp/rpa-browser/my_mod.rpa'); await saved.saveAs(archive); assert.match(await page.locator('#tools-result-description').textContent(), /Файлов в RPA: 4/); + assert(await page.locator('#tools-pack-integration').isVisible()); + assert.match(await page.locator('#tools-pack-install-archive').textContent(), /my_mod\.rpa$/); + assert(await page.locator('#tools-export-dialog').isVisible()); + assert.match(await page.locator('#tools-export-status').textContent(), /Скачивание запущено/); + assert.equal(await page.locator('.tools-dialog-downloads :is(button, a):visible').count(), 1); + assert.equal(await page.locator('#tools-download-all').innerText(), 'Скачать'); + assert(await page.locator('#tools-download-all .tools-icon-download').isVisible()); + assert(await page.locator('#tools-export-info').isVisible()); + const repeated = page.waitForEvent('download'); + await page.locator('#tools-download-all').click(); + assert.equal((await repeated).suggestedFilename(), 'my_mod.rpa'); + const downloadReview = path.join(root, 'temp/tools-workflow-review'); + fs.mkdirSync(downloadReview, { recursive: true }); + for (const [name, width, theme] of [['download-desktop', 1440, 'light'], ['download-mobile', 390, 'light'], + ['download-desktop-dark', 1440, 'dark'], ['download-mobile-dark', 390, 'dark']]) { + await page.setViewportSize({ width, height: 1000 }); + await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({ path: path.join(downloadReview, name + '.png') }); + } + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.evaluate(() => document.documentElement.dataset.theme = 'light'); + await page.keyboard.press('Escape'); + assert(!await page.locator('#tools-export-dialog').isVisible()); + assert(await page.locator('#tools-export-start').evaluate(el => el === document.activeElement)); + await page.locator('#tools-export-start').click(); + assert(await page.locator('#tools-export-dialog').isVisible()); + await page.locator('#tools-export-close').click(); assert.equal(await page.locator('#tools-queue').isVisible(), true); await page.locator('#tools-browser-search').fill('/'); assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths); await page.locator('#tools-pack-prefix').fill('alternate'); + assert(!await page.locator('#tools-pack-integration').isVisible()); assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').first().textContent(), 'alternate/' + verifiedPaths[0]); - assert(await page.locator('#tools-download-all').isEnabled()); + assert(await page.locator('#tools-export-start').isEnabled()); await page.locator('#tools-pack-prefix').fill(''); const python = path.join(root, '.venv/Scripts/python.exe'); execFileSync(python, ['-c', [ @@ -104,8 +139,11 @@ for (const [name, data] of Object.entries(resources)) { // Open our output in the existing unpacker, including Unicode names. await page.locator('#tools-mode-unpack').check(); + assert(!await page.locator('#tools-options').isVisible()); await page.locator('#tools-files').setInputFiles(archive); await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 }); + assert(await page.locator('#tools-options').isVisible()); + assert.equal(await page.locator('#tools-add-label').textContent(), 'Добавить ещё'); await page.locator('#tools-mode-pack').check(); await page.locator('#tools-browser-search').fill('/'); assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths); @@ -114,11 +152,24 @@ for (const [name, data] of Object.entries(resources)) { await page.locator('#tools-browser-search').fill('тема'); assert.match(await page.locator('#tools-browser-list').textContent(), /тема.ogg/); const extracted = page.waitForEvent('download', { timeout: 90000 }); - await page.locator('#tools-download-all').click(); + await page.locator('#tools-export-start').click(); const zip = await extracted; assert.equal(zip.suggestedFilename(), 'my_mod.zip'); const zipPath = path.join(root, 'temp/rpa-browser/my_mod.zip'); await zip.saveAs(zipPath); + assert(await page.locator('#tools-export-dialog').isVisible()); + assert.equal(await page.locator('#tools-export-title').textContent(), 'Готово'); + for (const [name, width, theme] of [['unpack-dialog-desktop', 1440, 'light'], ['unpack-dialog-mobile', 390, 'light'], + ['unpack-dialog-desktop-dark', 1440, 'dark'], ['unpack-dialog-mobile-dark', 390, 'dark']]) { + await page.setViewportSize({ width, height: 1000 }); + await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); + await page.screenshot({ path: path.join(downloadReview, name + '.png') }); + } + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.evaluate(() => document.documentElement.dataset.theme = 'light'); + await page.locator('#tools-export-close').click(); + assert(!await page.locator('#tools-options').isVisible()); + assert.equal(await page.locator('#tools-add-label').textContent(), 'Добавить файлы или папку'); execFileSync(python, ['-c', [ 'import pathlib, zipfile, sys', 'with zipfile.ZipFile(sys.argv[1]) as archive:', @@ -128,6 +179,25 @@ for (const [name, data] of Object.entries(resources)) { ].join('\n'), zipPath, fixtures]); assert.deepEqual(errors, []); + // Multiple outputs and failed exports stay in the same dialog. + await page.locator('#tools-files').setInputFiles(['first.rpa', 'second.rpa'].map(name => ({ + name, mimeType: 'application/octet-stream', buffer: fs.readFileSync(archive), + }))); + await page.locator('#tools-export-start').click(); + await page.waitForFunction(() => document.querySelector('#tools-export-title').textContent === 'Готово' + && document.querySelectorAll('#tools-downloads a').length === 2); + assert(!await page.locator('#tools-download-all').isVisible()); + assert.deepEqual(await page.locator('#tools-downloads a').evaluateAll(links => links.map(link => link.download)), ['first.zip', 'second.zip']); + assert.equal(await page.locator('#tools-downloads a:visible').count(), 2); + await page.locator('#tools-export-close').click(); + await page.locator('#tools-files').setInputFiles({ name: 'bad.rpyc', mimeType: 'application/octet-stream', buffer: Buffer.from('invalid') }); + await page.locator('#tools-export-start').click(); + await page.waitForFunction(() => document.querySelector('#tools-export-title').textContent === 'Не удалось обработать файлы'); + assert(await page.locator('#tools-export-errors').isVisible()); + assert.equal(await page.locator('#tools-download-all').innerText(), 'Повторить обработку'); + assert(await page.locator('#tools-download-all').isEnabled()); + await page.locator('#tools-export-close').click(); + // Remove a folder by keyboard, including its descendants, then the remaining root. await page.locator('#tools-mode-pack').check(); await page.locator('#tools-clear').click(); @@ -145,8 +215,10 @@ for (const [name, data] of Object.entries(resources)) { await page.getByRole('button', { name: 'Убрать папку my_mod/ и все вложенные файлы', exact: true }).focus(); await page.keyboard.press('Enter'); assert(!await page.locator('#tools-browser').isVisible()); + assert(!await page.locator('#tools-pack-settings').isVisible()); + assert.equal(await page.locator('#tools-add-label').textContent(), 'Добавить файлы или папку'); assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); - assert(await page.locator('#tools-download-all').isDisabled()); + assert(!await page.locator('#tools-export-start').isVisible()); // Reach a file beyond the former 200-row limit, then remove it by keyboard. await page.locator('#tools-files').setInputFiles(Array.from({ length: 251 }, (_, i) => ({ @@ -172,7 +244,7 @@ for (const [name, data] of Object.entries(resources)) { assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement)); // One visual inspection round, desktop and mobile / light and dark. - const review = path.join(root, '.impeccable/review'); + const review = path.join(root, 'temp/tools-workflow-review'); fs.mkdirSync(review, { recursive: true }); for (const [name, width, theme] of [['desktop', 1440, 'light'], ['mobile', 390, 'light'], ['desktop-dark', 1440, 'dark'], ['mobile-dark', 390, 'dark']]) { @@ -180,7 +252,7 @@ for (const [name, data] of Object.entries(resources)) { await page.goto(target); await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); await page.locator('#tools-folder').setInputFiles(fixtures); - await page.locator('#tools-download-all').waitFor({ state: 'visible' }); + await page.locator('#tools-export-start').waitFor({ state: 'visible' }); await page.evaluate(() => document.fonts.ready); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), name + ' overflow'); const contrasts = await page.locator('#tools-pack-settings input, #tools-pack-settings select, #tools-queue-search').evaluateAll(fields => { diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs index f8e68d0..1a05d65 100644 --- a/tests/tools_ui.test.mjs +++ b/tests/tools_ui.test.mjs @@ -6,7 +6,7 @@ import { test } from 'node:test'; function page(search = '', withClipboard = false) { class Element { listeners = new Map(); children = []; hidden = false; disabled = false; - value = ''; files = []; clicks = 0; style = {}; attributes = {}; + value = ''; files = []; clicks = 0; style = {}; attributes = {}; open = false; classList = { add() {}, remove() {}, toggle() {} }; addEventListener(type, callback) { const handlers = this.listeners.get(type) || []; @@ -17,6 +17,8 @@ function page(search = '', withClipboard = false) { } click() { this.clicks++; this.emit('click'); } focus() { document.activeElement = this; } + showModal() { this.open = true; } + close() { this.open = false; this.emit('close'); } contains(target) { return target === this || this.children.includes(target); } getBoundingClientRect() { return { left: 0, bottom: 40, width: 210, height: 100 }; } setAttribute(key, value) { this.attributes[key] = value; } @@ -104,6 +106,125 @@ function page(search = '', withClipboard = false) { } const script = { id: '0:script.rpyc', path: 'script.rpyc', source: 0, size: 1 }; +test('export dialog groups processing and completion, and reopening does not export or download again', async () => { + const ui = page(); + await ui.add(['script.rpyc'], [script]); + ui.get('export-start').click(); + assert.equal(ui.get('export-dialog').open, true); + assert.equal(ui.get('export-title').textContent, 'Обработка файлов'); + assert.equal(ui.get('export-start').disabled, true); + assert.match(ui.get('export-status').textContent, /Подготовка/); + ui.exportDone(); + assert.equal(ui.get('export-title').textContent, 'Готово'); + assert.equal(ui.get('result-description').textContent, 'Обработано: 1.'); + assert.equal(ui.get('export-start').disabled, false); + const requests = ui.messages.length; + ui.get('export-close').click(); + assert.equal(ui.get('export-dialog').open, false); + assert.equal(ui.document.activeElement, ui.get('export-start')); + ui.get('export-start').click(); + assert.equal(ui.get('export-dialog').open, true); + assert.equal(ui.messages.length, requests); + assert.equal(ui.get('downloads').children[0].clicks, 1); + ui.get('download-all').click(); + assert.equal(ui.get('downloads').children[0].clicks, 2); +}); + +test('dismissing an export cancels processing and preserves files for a fresh attempt', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + await ui.add(['bg.png']); + ui.get('export-start').click(); + const worker = ui.workers.at(-1); + ui.get('export-dialog').emit('cancel', { preventDefault() {} }); + assert.equal(ui.get('export-dialog').open, false); + assert.equal(worker.terminated, true); + assert.equal(ui.get('queue').hidden, false); + assert.equal(ui.get('export-start').disabled, false); + ui.get('export-start').click(); + assert.equal(ui.get('export-dialog').open, true); + assert.equal(ui.workers.length, 2); + ui.send({ type: 'fatal', error: 'Out of memory' }); + assert.equal(ui.get('export-title').textContent, 'Не удалось обработать файлы'); + assert.equal(ui.get('export-errors').hidden, false); + assert.match(ui.get('export-status').textContent, /памяти/); + ui.get('export-close').click(); + ui.get('export-start').click(); + assert.equal(ui.workers.length, 2); + ui.get('download-all').click(); + assert.equal(ui.workers.length, 3); +}); + +test('a later preview failure preserves the saved export summary and diagnostics', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + await ui.add(['script.rpy']); + ui.get('export-start').click(); + ui.exportDone(['demo.rpa']); + const summary = ui.get('result-description').textContent; + ui.get('export-close').click(); + ui.get('browser-list').children[0].children[0].click(); + ui.send({ type: 'fatal', error: 'Out of memory' }); + assert.equal(ui.get('result-description').textContent, summary); + assert.equal(ui.get('export-errors').hidden, true); + assert.equal(ui.get('errors').hidden, false); + ui.get('export-start').click(); + assert.equal(ui.get('export-title').textContent, 'Готово'); + assert.equal(ui.get('export-dialog').open, true); +}); + +test('settings follow accepted files and the active mode, including removal of the last file', async () => { + const ui = page(); + assert.equal(ui.get('options').hidden, true); + assert.equal(ui.get('pack-settings').hidden, true); + await ui.add(['unsupported.png']); + assert.equal(ui.get('options').hidden, true); + await ui.add(['script.rpyc'], [script]); + assert.equal(ui.get('options').hidden, false); + assert.equal(ui.get('add-label').textContent, 'Добавить ещё'); + ui.get('mode-pack').emit('change'); + assert.equal(ui.get('options').hidden, true); + assert.equal(ui.get('pack-settings').hidden, true); + ui.get('pack-name').value = 'demo'; + await ui.add(['bg.png']); + assert.equal(ui.get('pack-settings').hidden, false); + ui.get('browser-list').querySelectorAll('.tools-remove-source')[0].click(); + assert.equal(ui.get('pack-settings').hidden, true); + assert.equal(ui.get('add-label').textContent, 'Добавить файлы или папку'); + ui.get('mode-unpack').emit('change'); + assert.equal(ui.get('options').hidden, false); + ui.get('clear').click(); + assert.equal(ui.get('options').hidden, true); +}); + +test('RPA connection appears only when a successful export starts downloading', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'chapter'; + assert.equal(ui.get('pack-integration').hidden, true); + await ui.add(['bg.png']); + assert.equal(ui.get('pack-integration').hidden, true); + ui.get('download-all').click(); + assert.equal(ui.get('pack-integration').hidden, true); + ui.exportDone(['chapter.rpa']); + assert.equal(ui.get('pack-integration').hidden, false); + assert.equal(ui.get('pack-install-archive').textContent, 'game/my_mod/chapter.rpa'); + assert.match(ui.get('export-status').textContent, /Скачивание запущено/); + ui.get('download-all').click(); + assert.equal(ui.get('pack-integration').hidden, false); + ui.get('pack-prefix').value = 'new'; + ui.get('pack-prefix').emit('input'); + assert.equal(ui.get('pack-integration').hidden, true); + assert.equal(ui.get('export-status').textContent, ''); + ui.get('download-all').click(); + ui.send({ type: 'fatal', error: 'Failed to create archive' }); + assert.equal(ui.get('pack-integration').hidden, true); + ui.get('download-all').click(); + ui.exportDone(['chapter.rpa']); + assert.equal(ui.get('pack-integration').hidden, false); + await ui.add(['music.ogg']); + assert.equal(ui.get('pack-integration').hidden, true); +}); + test('packing accepts resources and sends paths relative to game without cataloging them', async () => { const ui = page('?mode=pack'); ui.get('pack-name').value = 'demo'; @@ -545,6 +666,11 @@ test('download-all processes all sources, downloads automatically, and retains r assert.equal(ui.get('downloads').children[0].download, 'unrpyc.zip'); assert.equal(ui.get('downloads').children[0].clicks, 1); assert.equal(ui.get('queue').hidden, true); + assert.equal(ui.get('downloads').hidden, true); + assert.equal(ui.get('download-all').hidden, false); + assert.match(ui.get('download-all').innerHTML, /tools-icon-download/); + assert.match(ui.get('download-all').innerHTML, /Скачать<\/span>/); + assert.match(ui.get('export-info').textContent, /unrpyc.zip/); ui.get('download-all').click(); assert.equal(ui.get('downloads').children[0].clicks, 2); ui.window.__esdocToolsCleanup(); assert.equal(ui.revoked.length, 1); }); @@ -558,7 +684,12 @@ test('archives show contents before extraction and each exports as a separate ZI assert.equal(ui.get('browser-title').textContent, 'b.rpa'); assert.equal(ui.messages.length, 1); ui.get('download-all').click(); assert.equal(ui.messages.at(-1).mode, 'combined'); ui.exportDone(['a.zip', 'b.zip'], { ...ui.result, succeeded: 2, written: 2 }); - assert.deepEqual(ui.get('downloads').children.map(link => link.clicks), [1, 1]); + assert.equal(ui.get('download-all').hidden, true); + assert.equal(ui.get('downloads').hidden, false); + assert.match(ui.get('downloads').children[0].innerHTML, /tools-icon-download/); + assert.equal(ui.get('downloads').children[0].children[0].textContent, 'Скачать a.zip'); + ui.get('downloads').children[1].click(); + assert.deepEqual(ui.get('downloads').children.map(link => link.clicks), [1, 2]); }); test('clearing a mixed queue removes every source', async () => { @@ -631,9 +762,9 @@ test('partial export failure retains completed ZIP links', async () => { ui.send({ type: 'output', name: 'unrpyc.zip', result: ui.result }); ui.send({ type: 'fatal', error: 'Out of memory' }); assert.equal(ui.get('downloads').children.length, 1); assert.equal(ui.revoked.length, 0); - assert.match(ui.get('status').textContent, /памяти/); - assert.doesNotMatch(ui.get('status').textContent, /Out of memory/); - assert.equal(ui.get('errors').hidden, false); + assert.match(ui.get('export-status').textContent, /памяти/); + assert.doesNotMatch(ui.get('export-status').textContent, /Out of memory/); + assert.equal(ui.get('export-errors').hidden, false); }); test('cancelling a pending preview replaces loading and ignores late replies before retry', async () => { From 36308bad593b4008b8bc7fda1a7a0cd55f57579a Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:44:49 +0300 Subject: [PATCH 07/27] feature: character creator added --- .impeccable/design.json | 8 +- DESIGN.md | 14 +- app/routes/search_discovery.py | 2 +- app/routes/tools.py | 5 + app/utils/lifespan/search_cache.py | 1 + scripts/prepare_character_catalog.py | 47 ++ static/css/characters.css | 106 ++++ static/js/characters-catalog.js | 781 +++++++++++++++++++++++++++ static/js/characters-core.js | 206 +++++++ static/js/characters.js | 452 ++++++++++++++++ static/js/navigation.js | 1 + templates/partials/head.html | 4 + templates/tools.html | 1 + templates/tools_characters.html | 74 +++ tests/characters_browser.cjs | 162 ++++++ tests/characters_core.test.mjs | 88 +++ tests/test_tools.py | 16 + 17 files changed, 1965 insertions(+), 3 deletions(-) create mode 100644 scripts/prepare_character_catalog.py create mode 100644 static/css/characters.css create mode 100644 static/js/characters-catalog.js create mode 100644 static/js/characters-core.js create mode 100644 static/js/characters.js create mode 100644 templates/tools_characters.html create mode 100644 tests/characters_browser.cjs create mode 100644 tests/characters_core.test.mjs diff --git a/.impeccable/design.json b/.impeccable/design.json index 8042374..6994a2d 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-10-06T21:19:06Z", + "generatedAt": "2026-10-10T10:40:00Z", "title": "Design System: ES Doc", "extensions": { "colorMeta": { @@ -168,6 +168,12 @@ { "name": "narrow", "value": "400px" } ], "surfaceGuidance": { + "tools-characters": { + "layout": "Scoped 78rem settings-left / scene-and-code-right workbench, .9 / 1.15 columns with a 40px gap. Section navigation sticks at 64px and result at 132px; heading/field scroll margins are 140px. At 960px columns become equal, gap 24px and parameter groups one column. At 720px settings precede the static result with a 32px gap, common fields return to two columns, and section buttons become a fixed safe-area bottom bar. Reserve 88px below the page and use 80px top / 88px bottom scroll margins on mobile. Jumps focus destinations; Settings returns to the last connected edited field. Keep 44px controls and body-sized mobile inputs.", + "behavior": "Variable/name and five common fields lead; name semantics, inheritance, fonts, typography, behavior, screen and engine settings start in native disclosures. Each field discloses its key, type, explicit-value checkbox and reset. HEX preserves text alpha; the native chooser displays RGB without mutating text and an explicit selection sets an opaque color. Added fields have visible removal even when empty, returning focus to adding a property. Removing a configured value is undoable. Catalog search matches keys, Russian help/labels, inflections and task synonyms; a scope-switch recovery names and focuses matching results elsewhere. Existing-property selection reveals and focuses its input. Preserve omission versus explicit empty/False/None/zero and typed export; Python is unexecuted text with structural checks only. Per-field errors have accessible descriptions, block copy/.rpy and offer first-error focus. The approximate 1920 × 1080 scene starts enlarged 2× desktop / 3× mobile; full-scene view remains available. Reflect supported literal styling and converted padding; list unsupported configured properties. Explicit replay safely preserves supported text-tag spans and respects motion/speed/interruption settings. Local draft status names readiness, restored state, successful writes or storage failure. A 64-step history includes reset/removal/import; shortcuts act outside text editors. JSON settings and definition-only .rpy are distinct; collapsed usage shows the current valid variable. Failed import retains state and failed copy selects code. Preview files stay local/outside JSON. Navigation flushes edits, releases listeners/timers/URLs/font faces and rejects stale loads.", + "dataColors": "Name #e2c778, dialogue #f5ecd7, scene #253846 and textbox #18232ee8 are functional preview data, independent of site theme. User colors, local backgrounds and specimen fonts add no global accents or shipping assets. Controls, focus, errors and code stay on incumbent theme tokens.", + "typography": "PT Serif page title, Inter controls and help, monospace parameter keys/code. Scene-pixel, game-font-path and outline-tuple help uses the existing --fs-ui step. Mobile inputs use body size; preview text scales with scene width. No new global typography or color tokens." + }, "tools-colors": { "layout": "72rem workbench: code formats left, visual editor right, contrast below both. DOM and focus order match visual order. At 640px and below, formats/editor/contrast stack in the same order, quick section buttons scroll and focus without navigation or state reset, and four supporting tabs wrap. Keep this measure scoped to the color tool.", "behavior": "One sRGB selection synchronizes field/wheel, RGB/HSV/HSL/alpha, six always-visible editable format rows, harmonies, OKLab shades and composited contrast. Preserve active typing and valid color on errors. Arrow keys step the focusable area and numeric channels; picker-wide undo restores the preview pair and role. The role selector edits the existing text/background color, swap exchanges them, and Lucide reset restores defaults. Native EyeDropper is feature-detected. Clipboard failure selects the affected row or a manual palette-copy field. Local image metadata shows dimensions without filenames; loading/errors remain explicit. Each invalid field owns its visible error and accessible description. Saved collections join undo/redo; clearing also exposes immediate recovery preserving later color edits. Recent colors remain separate. The named-color palette preserves all 1012 supplied name/HEX pairs, searches names and full/short HEX case-insensitively with е/ё equivalence, shows names beside HEX and renders another 100 matches only on request. Delegate palette clicks and avoid repainting unchanged samples. Keyboard help sits beside the editor. Alpha controls use Russian transparency labels.", diff --git a/DESIGN.md b/DESIGN.md index 6fb31b8..e26b70d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -315,6 +315,8 @@ Standard header spacing is 32px above / 28px below, 40/32 for the homepage, and The color workbench uses a 72rem maximum and two desktop columns, with the editor beside the code result. At 640px and below, code and copy come first, the editor follows, then contrast; supporting tabs wrap rather than hiding destinations behind horizontal scrolling. This is a scoped tool layout, not a replacement for article or directory measures. +The character workbench uses a scoped 78rem maximum: settings left, approximate scene above code right, with .9 / 1.15 columns and a 40px gap. Section navigation sticks at 64px and the result at 132px. At 960px and below the columns become equal with a 24px gap and single-column parameter groups. At 720px and below the result becomes static after settings, separated by 32px; common fields return to two columns. Section navigation becomes a fixed bottom bar padded for the safe area, with 88px reserved below the page. Heading and field scroll margins are 140px on desktop and 80px above / 88px below on mobile. Section jumps focus their destination, and Settings returns to the last connected edited field. Controls retain 44px targets and mobile inputs use body-sized text. + ### Responsive and accessibility contract - Breakpoints use em and reflect content fit: 1280px (TOC), 1140px (header links), 992px (article tree), 800px (gutters), 768px (directory groups), 640px (search sheet). @@ -540,7 +542,7 @@ Tools warnings use the site's shared attention circle with an exclamation mark, Adding files or a folder displays every supported source together without decompiling or extracting them. Loose scripts are listed immediately without loading Python. RPA files load their validated indexes only. Opening a file extracts or decompiles that entry on demand; the current preview is cached, and changing decompilation options updates only the open file. The primary download below the browser processes all sources and automatically downloads the resulting ZIPs. Completed ZIP links remain available for retrying a blocked download. The retry button is for reading the catalog after cancellation or a loading error, never a required processing step. -`/tools` is a lightweight hub with flat linked rows for `/tools/unpack` and `/tools/colors`, followed by community tools. Individual tool pages link back to the hub and keep the Tools navigation active. Page-specific code loads only where needed. +`/tools` is a lightweight hub with flat linked rows for `/tools/unpack`, `/tools/colors` and `/tools/characters`, followed by community tools. Individual tool pages link back to the hub and keep the Tools navigation active. Page-specific code loads only where needed. `/tools/unpack` is the local Ren'Py workbench in the existing page frame: PT Serif headings, Inter controls, and a 66rem column. One queue accepts .rpa, .rpyc and .rpymc; there are no processing modes to select. Archive extraction also decompiles contained scripts. The source selector groups standalone «Сценарии» and individual archives without changing processing scope. «Скачать всё в ZIP» always exports the whole queue. Clearing or completing a download clears every source; cancellation keeps sources. The queue renders at most 200 rows and the browser at most 500 entries as DOM optimizations; all sources are still processed. The dashed drop area accepts files and recursive folders, turns solid with `--accent-muted` during drag, and offers file/folder selection through its button, context menu and keyboard. Controls keep 44px targets and the shared focus ring. Settings and «Подробнее» remain flat native disclosures; the filename search, breadcrumbs, file-type icons and current-file state support scanning. The list and preview sit side by side on desktop and stack on mobile. Download controls stay below the browser, with per-file downloads below each preview. @@ -560,6 +562,16 @@ Image selection accepts PNG, JPEG, WebP, GIF and AVIF locally, up to 40 MiB. Sam Processing runs in a lazily loaded local module Worker using Pyodide and full upstream unrpyc/unrpa. Files stay on the device. There are no application quotas on queue size, script size, archive-index size, entry count or ZIP output; device memory determines capacity. Integrity validation remains: restricted pickle globals, safe paths, valid segment ranges and complete compressed streams. Shared archive segments are validated once and reused as immutable tuples. Each RPA produces its own ZIP named from the archive basename (`data.rpa` → `data.zip`), preserving exact internal paths. Extraction retains compiled originals, adds recovered code and preserves existing source files; ZIPs contain no generated report. Standalone scripts share `unrpyc.zip`. Previewing a compiled archive entry also preserves an existing source, matching export behavior. Local Pygments uses the same Ren'Py lexer and `syntax.css` as the resource browser, with whitespace dots, line numbers excluded from selection, and the shared clipboard control. Image, audio (including Opus and FLAC), video and font previews stay local too. Text above 2 MiB remains downloadable without rendering hundreds of thousands of lines; this is only a preview limit, not a processing or export quota. Warning counts expand to show actual diagnostics. Source links and format help remain visible without JavaScript. Soft navigation terminates the worker and releases listeners, font registrations and download URLs. No new design tokens or raster assets are introduced. +### Character creator + +`/tools/characters` is a practical settings-to-code workbench in the existing leaf day / lake night world. Variable and name lead, followed by five common fields: name color / size, dialogue color / size and printing speed. Name semantics, inheritance, fonts, typography, behavior, screen and engine settings start in native disclosures. Each field keeps its parameter key, value type, explicit-value checkbox and reset under «Параметр / Python». Editable HEX fields accept alpha; the native chooser displays RGB without mutating that text, and an explicit chooser selection sets an opaque color. Russian help explains scene pixels, paths inside game and outline tuples, using the existing `--fs-ui` step. Added fields have a visible Remove action even when empty; removing a configured value is undoable and returns focus to adding a property. + +The searchable catalog matches keys, Russian labels/help, common inflections and task synonyms, including speed, outlines and font paths. When only another scope has a match, a named scope-switch action moves focus to its results. Adding an existing property opens its ancestor disclosures and focuses its input. Unset values remain omitted from Character so the game can inherit them; explicit empty strings, False, None and zero remain distinct. Text is quoted, finite numbers and booleans retain their types, and Python stays unquoted text that is never executed. Structural expression checks cover quotes, brackets and a single expression. Individual fields own visible Russian errors and accessible descriptions; invalid definitions disable copy and .rpy download, with «Перейти к ошибке» revealing and focusing the first invalid field. + +The browser scene approximates 1920 × 1080 and scales text with its width. Desktop starts at twice the composition scale and mobile at three times; «Вся сцена» restores the composition view. Supported literal colors, typography, outlines, text geometry and window/namebox color/padding are reflected; Ren'Py padding order is converted to CSS order. Unsupported configured properties are listed individually under «Что требует проверки в Ren'Py». The name (`#e2c778`), dialogue (`#f5ecd7`), scene (`#253846`) and textbox (`#18232ee8`) colors are preview data, independent of the site theme. Local backgrounds and specimen fonts are also user data. Interface controls, errors, focus and code retain the global tokens. Supported {b}, {i}, {u}, {s}, {color} and {size} tags render safely; explicit replay preserves their spans, respects reduced motion and speed/interruption settings, and does not restart during editing. + +The local draft status distinguishes initial readiness, restored settings, successful writes and unavailable storage, with file-save recovery. A 64-step undo / redo history includes reset, field removal and imports. Ctrl+Z / Ctrl+Shift+Z and Ctrl+Y apply outside text editors, preserving native editor undo. JSON saves/reopens the versioned constructor settings; .rpy exports only the Character definition. A separate collapsed usage example uses the current valid variable and explains where to put a dialogue statement. Import errors retain the current character, and clipboard failure selects code with manual-copy feedback. PNG / JPG / WebP backgrounds and TTF / OTF / WOFF / WOFF2 fonts stay local and outside the JSON settings; game font paths are separate parameters. Soft navigation saves pending edits, stops timers, aborts listeners, releases object URLs/font faces and rejects stale loads. This implemented surface adds no global tokens or shipping raster assets. + ## Do's and Don'ts ### Do: diff --git a/app/routes/search_discovery.py b/app/routes/search_discovery.py index b32a9f1..09930cb 100644 --- a/app/routes/search_discovery.py +++ b/app/routes/search_discovery.py @@ -15,7 +15,7 @@ STATIC_PAGES = ( '/', '/docs/', '/authors', '/specialists', '/materials', '/news-resources', - '/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers', + '/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers', '/tools/characters', ) diff --git a/app/routes/tools.py b/app/routes/tools.py index c34191a..e3d4b51 100644 --- a/app/routes/tools.py +++ b/app/routes/tools.py @@ -50,6 +50,11 @@ async def colors_page(request: Request): return templates.TemplateResponse(request, 'tools_colors.html', {}) +@main_router.get('/tools/characters') +async def characters_page(request: Request): + return templates.TemplateResponse(request, 'tools_characters.html', {}) + + @main_router.get('/tools/unpack') async def unpack_page(request: Request): return templates.TemplateResponse(request, 'tools_unpack.html', { diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py index e6227ff..06faec5 100644 --- a/app/utils/lifespan/search_cache.py +++ b/app/utils/lifespan/search_cache.py @@ -19,6 +19,7 @@ ('Инструменты', '/tools'), ('Архивация RPA и декомпиляция RPYC/PYC / Упаковщик и распаковщик RPA / RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'), ('Палитра цветов / Цвет / HEX / RGB / Палитры', '/tools/colors'), + ('Создатель персонажей / Character / RenPy / Имя / Диалог / Повествование / Стиль текста', '/tools/characters'), ('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'), ('Площадки для публикации', '/news-resources'), ('Авторы', '/authors'), diff --git a/scripts/prepare_character_catalog.py b/scripts/prepare_character_catalog.py new file mode 100644 index 0000000..38dc07d --- /dev/null +++ b/scripts/prepare_character_catalog.py @@ -0,0 +1,47 @@ +"""Refresh the property names/types from the official Ren'Py style reference. + +Run with the site's Python: scripts/prepare_character_catalog.py [saved-style.html]. +The generated catalog is committed; visitors never fetch external documentation. +""" +import html +import json +import re +import sys +from pathlib import Path +from urllib.request import urlopen + +SOURCE = 'https://www.renpy.org/doc/html/style_properties.html' +ROOT = Path(__file__).resolve().parents[1] + + +def generate(source): + group = '' + result = [] + tokens = re.finditer(r'
    |]*id="style-property-([^"]+)"[^>]*>(.*?)', source, re.DOTALL) + for match in tokens: + if match[1]: + if match[1].endswith('-style-properties'): + group = match[1].removesuffix('-style-properties') + continue + signature = html.unescape(re.sub('<[^>]+>', '', match[3])).replace('\uf0c1', '').strip() + datatype = signature.split(' -', 1)[-1].strip() + mode = 'expression' + if datatype == 'boolean': + mode = 'boolean' + elif datatype in ('int', 'float'): + mode = 'number' + elif datatype in ('string', 'color'): + mode = 'text' + result.append({'name': match[2], 'type': datatype, 'mode': mode, 'group': group}) + if len(result) < 120: + raise ValueError('Incomplete style reference') + catalog = {'source': SOURCE, 'version': '8.5.4', 'styles': result} + output = '// Generated by scripts/prepare_character_catalog.py from the official Ren\'Py reference.\n' + output += 'window.ESDocCharacterCatalog = ' + json.dumps(catalog, ensure_ascii=False, indent=2) + ';\n' + (ROOT / 'static/js/characters-catalog.js').write_text(output, 'utf-8') + print(f'{len(result)} style properties written') + + +if __name__ == '__main__': + source = Path(sys.argv[1]).read_text('utf-8') if len(sys.argv) > 1 else urlopen(SOURCE).read().decode('utf-8') + generate(source) diff --git a/static/css/characters.css b/static/css/characters.css new file mode 100644 index 0000000..a1fa534 --- /dev/null +++ b/static/css/characters.css @@ -0,0 +1,106 @@ +.characters-page .page-main { max-width: 78rem; } +.characters-page [hidden] { display: none !important; } +.characters-page .page-main a { color: var(--accent); text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent); text-underline-offset: 3px; } +.characters-page .page-main a:hover { color: var(--accent-hover); text-decoration-color: currentColor; } +.characters-page :is(input, textarea, select, button) { font: inherit; font-family: var(--font-sans); } +.characters-page :is(input:not([type="checkbox"]):not([type="color"]), select, textarea) { box-sizing: border-box; min-width: 0; width: 100%; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font-size: var(--fs-ui); } +.characters-page :is(input, textarea, select)::placeholder { color: var(--text-soft); opacity: 1; } +.characters-page :is(input, textarea, button, select, summary, h2):focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.characters-page [aria-invalid="true"] { border-color: var(--danger) !important; } +.character-toolbar, .character-section-head, .character-actions, .character-project-actions { display: flex; align-items: center; gap: 8px; } +.character-toolbar { justify-content: space-between; gap: 24px; margin-bottom: 26px; } +.character-toolbar p { margin: 0; color: var(--text-soft); max-width: 62ch; font-size: var(--fs-ui); } +.character-button, .character-icon { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); background: var(--bg); cursor: pointer; font-size: var(--fs-ui) !important; } +.character-icon { padding: 10px; min-width: 44px; border-color: transparent; } +.character-button:hover:enabled, .character-icon:hover:enabled { color: var(--accent); background: var(--bg-light); } +.character-button:disabled, .character-icon:disabled { opacity: .5; cursor: default; } +.character-primary { color: var(--bg); background: var(--accent); border-color: var(--accent); } +.character-primary:hover:enabled { color: var(--bg); background: var(--accent-hover); border-color: var(--accent-hover); } +.character-workbench { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.15fr); align-items: start; gap: 40px; } +.character-settings, .character-result { min-width: 0; } +.character-result { position: sticky; top: 132px; } +.character-settings h2, .character-section-head h2 { margin: 0; font: 600 1rem/1.4 var(--font-sans); } +.character-settings h2 { margin-bottom: 18px; } +.character-identity, .character-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; } +.character-identity { margin-bottom: 16px; } +.character-field { display: grid; align-content: start; gap: 6px; min-width: 0; } +.character-wide, .character-parameter { grid-column: 1 / -1; } +.character-field label, .character-preview-controls label, .character-property-picker label { font-size: var(--fs-ui); } +.character-field-key { font: var(--fs-ui)/1.4 var(--font-mono); color: var(--text-soft); overflow-wrap: anywhere; } +.character-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.character-enable { display: inline-flex; align-items: center; gap: 6px; color: var(--text-soft); font-size: var(--fs-ui) !important; min-height: 44px; cursor: pointer; } +.character-enable input { margin: 0; width: 16px; height: 16px; } +.character-field-value { display: flex; align-items: center; gap: 6px; min-width: 0; } +.character-value { flex: 1; min-width: 0; } +.character-mode { width: 100% !important; } +.character-field-help { font-size: var(--fs-ui); line-height: 1.5; margin: 0; color: var(--text-soft); overflow-wrap: anywhere; } +.character-field-options { min-width: 0; font-size: var(--fs-ui); } +.character-field-options > summary { min-height: 44px; align-content: center; color: var(--text-soft); cursor: pointer; } +.character-field-options[open] { display: grid; gap: 8px; padding-bottom: 8px; } +.character-color { flex: none; width: 44px; height: 44px; padding: 4px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--input-bg); cursor: pointer; } +#character-quick-fields > .character-field:last-child { grid-column: 1 / -1; } +#character-identity-fields { margin-top: 18px; } +.character-usage { padding: 12px; overflow: auto; background: var(--code-bg); font: var(--fs-ui)/1.6 var(--font-mono); border-radius: var(--radius-sm); } +.character-disclosure { border-top: 1px solid var(--border); margin-top: 24px; } +.character-disclosure > summary { padding: 16px 0; font-weight: 600; cursor: pointer; font-size: var(--fs-ui); } +.character-disclosure > summary > span { font: 12px/1.5 var(--font-mono); color: var(--text-soft); margin-left: 8px; } +.character-disclosure > .character-fields { padding: 4px 0 8px; } +.character-help { font-size: var(--fs-ui); line-height: 1.6; color: var(--text-soft); margin: 12px 0; } +.character-error { color: var(--danger); font-size: var(--fs-ui); line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.character-section-head { justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; } +.character-stage { --character-preview-scale: 1; aspect-ratio: 16 / 9; position: relative; overflow: hidden; background-color: #253846; background-position: center; background-size: cover; border-radius: var(--radius-sm); container-type: inline-size; } +.character-stage.is-zoomed { --character-preview-scale: 2; aspect-ratio: 16 / 7; } +.character-stage.is-zoomed .character-dialogue-window { min-height: 68%; } +.character-dialogue-window { position: absolute; left: 0; right: 0; bottom: 0; min-height: 31%; padding: 5% 8% 5%; box-sizing: border-box; background: #18232ee8; color: #f5ecd7; font-family: var(--font-sans); } +.character-namebox { margin-bottom: 1.5%; width: fit-content; max-width: 100%; } +#character-preview-who { font-size: calc(1.667cqw * var(--character-preview-scale)); color: #e2c778; font-weight: 600; line-height: 1.35; white-space: pre-wrap; overflow-wrap: anywhere; } +#character-preview-what { font-size: calc(1.667cqw * var(--character-preview-scale)); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } +.character-sample { margin-top: 18px; } +.character-sample textarea { resize: vertical; min-height: 76px; } +.character-preview-options { margin-top: 12px; font-size: var(--fs-ui); } +.character-preview-options > summary, .character-custom-disclosure > summary { min-height: 44px; display: list-item; align-content: center; cursor: pointer; color: var(--text-soft); } +.character-preview-options ul { margin: 8px 0 0; padding-left: 22px; } +.character-preview-options li { overflow-wrap: anywhere; } +.character-preview-controls { display: grid; gap: 8px; margin: 8px 0 16px; } +.character-preview-controls input[type="color"] { min-width: 64px; height: 44px; border: 1px solid var(--border); padding: 4px; background: var(--bg); } +.character-code-section { margin-top: 28px; } +.characters-page .character-code { font: var(--fs-ui)/1.65 var(--font-mono); background: var(--code-bg); min-height: 100px; max-height: 400px; resize: vertical; tab-size: 4; } +.character-status { min-height: 1.5em; font-size: var(--fs-ui); color: var(--text-soft); margin: 8px 0; } +.character-project-actions { flex-wrap: wrap; margin-top: 16px; } +.character-docs { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: var(--fs-ui); margin-top: 20px; } +.character-property-picker { display: grid; gap: 8px; padding: 8px 0 16px; } +.character-property-picker select { text-overflow: ellipsis; } +.character-custom-disclosure { margin-bottom: 18px; } +.character-custom-add { display: flex; align-items: center; gap: 8px; margin: 8px 0; } +.character-custom-add input { flex: 1; } +.character-custom-add button { flex: none; } +.character-parameter { border-top: 1px solid var(--border); padding-top: 16px; } +.character-parameter .character-field-head > label { overflow-wrap: anywhere; } +.character-jumps { display: flex; gap: 8px; position: sticky; top: 64px; z-index: 5; background: var(--bg); padding: 8px 0; margin-bottom: 16px; border-bottom: 1px solid var(--border); } +.character-jumps button { min-height: 44px; padding: 8px 12px; background: var(--bg-light); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui); cursor: pointer; } +.character-jumps button:hover { color: var(--accent); } +.characters-page :is(h2, .character-field input, .character-field select) { scroll-margin-top: 140px; } +@media (max-width: 960px) { + .character-workbench { gap: 24px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .character-fields { grid-template-columns: 1fr; } +} +@media (max-width: 720px) { + .character-toolbar { align-items: flex-start; flex-direction: column; gap: 12px; } + .character-workbench { display: flex; flex-direction: column; gap: 32px; } + .character-settings, .character-result { width: 100%; } + .character-result { position: static; } + .character-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .character-field-value { flex-wrap: wrap; } + .character-field-value { flex-wrap: nowrap; } + .character-parameter .character-field-head { flex-wrap: wrap; } + .character-enable { min-height: 44px; } + .character-stage.is-zoomed { --character-preview-scale: 3; aspect-ratio: 16 / 9; } + .characters-page :is(input:not([type="checkbox"]), textarea, select) { font-size: 1rem; } + .character-jumps { position: fixed; top: auto; bottom: 0; left: 0; right: 0; margin: 0; padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); border-bottom: 0; } + .character-jumps button { flex: 1; padding: 8px 4px; } + .characters-page .page-main { padding-bottom: 88px; } + .characters-page :is(h2, .character-field input, .character-field select) { scroll-margin-top: 80px; scroll-margin-bottom: 88px; } +} +@media (prefers-reduced-motion: no-preference) { + .character-button, .character-icon { transition: background-color 150ms ease-out, color 150ms ease-out; } +} diff --git a/static/js/characters-catalog.js b/static/js/characters-catalog.js new file mode 100644 index 0000000..bd3a4c2 --- /dev/null +++ b/static/js/characters-catalog.js @@ -0,0 +1,781 @@ +// Generated by scripts/prepare_character_catalog.py from the official Ren'Py reference. +window.ESDocCharacterCatalog = { + "source": "https://www.renpy.org/doc/html/style_properties.html", + "version": "8.5.4", + "styles": [ + { + "name": "alt", + "type": "string or None", + "mode": "expression", + "group": "position" + }, + { + "name": "xpos", + "type": "position", + "mode": "expression", + "group": "position" + }, + { + "name": "ypos", + "type": "position", + "mode": "expression", + "group": "position" + }, + { + "name": "pos", + "type": "tuple of (position, position)", + "mode": "expression", + "group": "position" + }, + { + "name": "xanchor", + "type": "position", + "mode": "expression", + "group": "position" + }, + { + "name": "yanchor", + "type": "position", + "mode": "expression", + "group": "position" + }, + { + "name": "anchor", + "type": "tuple of (position, position)", + "mode": "expression", + "group": "position" + }, + { + "name": "xalign", + "type": "float", + "mode": "number", + "group": "position" + }, + { + "name": "yalign", + "type": "float", + "mode": "number", + "group": "position" + }, + { + "name": "align", + "type": "tuple of (float, float)", + "mode": "expression", + "group": "position" + }, + { + "name": "xcenter", + "type": "position", + "mode": "expression", + "group": "position" + }, + { + "name": "ycenter", + "type": "position", + "mode": "expression", + "group": "position" + }, + { + "name": "xycenter", + "type": "tuple of (position, position)", + "mode": "expression", + "group": "position" + }, + { + "name": "xoffset", + "type": "int", + "mode": "number", + "group": "position" + }, + { + "name": "yoffset", + "type": "int", + "mode": "number", + "group": "position" + }, + { + "name": "offset", + "type": "tuple of (int, int)", + "mode": "expression", + "group": "position" + }, + { + "name": "xmaximum", + "type": "int", + "mode": "number", + "group": "position" + }, + { + "name": "ymaximum", + "type": "int", + "mode": "number", + "group": "position" + }, + { + "name": "maximum", + "type": "tuple of (int, int)", + "mode": "expression", + "group": "position" + }, + { + "name": "xminimum", + "type": "int", + "mode": "number", + "group": "position" + }, + { + "name": "yminimum", + "type": "int", + "mode": "number", + "group": "position" + }, + { + "name": "minimum", + "type": "tuple of (int, int)", + "mode": "expression", + "group": "position" + }, + { + "name": "xsize", + "type": "int or float", + "mode": "expression", + "group": "position" + }, + { + "name": "ysize", + "type": "int or float", + "mode": "expression", + "group": "position" + }, + { + "name": "xysize", + "type": "tuple of (int or float, int or float)", + "mode": "expression", + "group": "position" + }, + { + "name": "xfill", + "type": "boolean", + "mode": "boolean", + "group": "position" + }, + { + "name": "yfill", + "type": "boolean", + "mode": "boolean", + "group": "position" + }, + { + "name": "area", + "type": "tuple of (int, int, int, int)", + "mode": "expression", + "group": "position" + }, + { + "name": "mipmap", + "type": "boolean or None", + "mode": "expression", + "group": "position" + }, + { + "name": "antialias", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "adjust_spacing", + "type": "boolean or str", + "mode": "expression", + "group": "text" + }, + { + "name": "altruby_style", + "type": "style or None", + "mode": "expression", + "group": "text" + }, + { + "name": "axis", + "type": "dict or None", + "mode": "expression", + "group": "text" + }, + { + "name": "black_color", + "type": "color", + "mode": "text", + "group": "text" + }, + { + "name": "bold", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "caret", + "type": "displayable or None", + "mode": "expression", + "group": "text" + }, + { + "name": "color", + "type": "color", + "mode": "text", + "group": "text" + }, + { + "name": "emoji_font", + "type": "string", + "mode": "text", + "group": "text" + }, + { + "name": "first_indent", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "font", + "type": "string", + "mode": "text", + "group": "text" + }, + { + "name": "font_features", + "type": "dict or None", + "mode": "expression", + "group": "text" + }, + { + "name": "hinting", + "type": "str", + "mode": "expression", + "group": "text" + }, + { + "name": "hyperlink_functions", + "type": "tuple of (function, function, function)", + "mode": "expression", + "group": "text" + }, + { + "name": "instance", + "type": "string or None", + "mode": "expression", + "group": "text" + }, + { + "name": "italic", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "justify", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "kerning", + "type": "float", + "mode": "number", + "group": "text" + }, + { + "name": "language", + "type": "string", + "mode": "text", + "group": "text" + }, + { + "name": "layout", + "type": "string", + "mode": "text", + "group": "text" + }, + { + "name": "line_leading", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "line_overlap_split", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "line_spacing", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "min_width", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "newline_indent", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "outlines", + "type": "list of tuple of (int, color, int, int)", + "mode": "expression", + "group": "text" + }, + { + "name": "outline_scaling", + "type": "string", + "mode": "text", + "group": "text" + }, + { + "name": "prefer_emoji", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "reading_order", + "type": "string", + "mode": "text", + "group": "text" + }, + { + "name": "rest_indent", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "ruby_line_leading", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "ruby_style", + "type": "style or None", + "mode": "expression", + "group": "text" + }, + { + "name": "shaper", + "type": "\"harfbuzz\" or \"freetype\".", + "mode": "expression", + "group": "text" + }, + { + "name": "size", + "type": "int", + "mode": "number", + "group": "text" + }, + { + "name": "slow_abortable", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "slow_cps", + "type": "int or True", + "mode": "expression", + "group": "text" + }, + { + "name": "slow_cps_multiplier", + "type": "float", + "mode": "number", + "group": "text" + }, + { + "name": "strikethrough", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "textalign", + "type": "float", + "mode": "number", + "group": "text" + }, + { + "name": "underline", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "vertical", + "type": "boolean", + "mode": "boolean", + "group": "text" + }, + { + "name": "background", + "type": "displayable or None", + "mode": "expression", + "group": "window" + }, + { + "name": "foreground", + "type": "displayable or None", + "mode": "expression", + "group": "window" + }, + { + "name": "left_padding", + "type": "int", + "mode": "number", + "group": "window" + }, + { + "name": "right_padding", + "type": "int", + "mode": "number", + "group": "window" + }, + { + "name": "xpadding", + "type": "int", + "mode": "number", + "group": "window" + }, + { + "name": "top_padding", + "type": "int", + "mode": "number", + "group": "window" + }, + { + "name": "bottom_padding", + "type": "int", + "mode": "number", + "group": "window" + }, + { + "name": "ypadding", + "type": "int", + "mode": "number", + "group": "window" + }, + { + "name": "padding", + "type": "tuple", + "mode": "expression", + "group": "window" + }, + { + "name": "size_group", + "type": "string or None", + "mode": "expression", + "group": "window" + }, + { + "name": "modal", + "type": "boolean or callable", + "mode": "expression", + "group": "window" + }, + { + "name": "child", + "type": "displayable or None", + "mode": "expression", + "group": "button" + }, + { + "name": "hover_sound", + "type": "string", + "mode": "text", + "group": "button" + }, + { + "name": "activate_sound", + "type": "string", + "mode": "text", + "group": "button" + }, + { + "name": "mouse", + "type": "string", + "mode": "text", + "group": "button" + }, + { + "name": "focus_mask", + "type": "multiple", + "mode": "expression", + "group": "button" + }, + { + "name": "keyboard_focus", + "type": "boolean", + "mode": "boolean", + "group": "button" + }, + { + "name": "keyboard_focus_insets", + "type": "(int, int, int, int) or None", + "mode": "expression", + "group": "button" + }, + { + "name": "key_events", + "type": "boolean", + "mode": "boolean", + "group": "button" + }, + { + "name": "bar_vertical", + "type": "boolean", + "mode": "boolean", + "group": "bar" + }, + { + "name": "bar_invert", + "type": "boolean", + "mode": "boolean", + "group": "bar" + }, + { + "name": "bar_resizing", + "type": "boolean", + "mode": "boolean", + "group": "bar" + }, + { + "name": "left_gutter", + "type": "int", + "mode": "number", + "group": "bar" + }, + { + "name": "right_gutter", + "type": "int", + "mode": "number", + "group": "bar" + }, + { + "name": "top_gutter", + "type": "int", + "mode": "number", + "group": "bar" + }, + { + "name": "bottom_gutter", + "type": "int", + "mode": "number", + "group": "bar" + }, + { + "name": "left_bar", + "type": "displayable", + "mode": "expression", + "group": "bar" + }, + { + "name": "right_bar", + "type": "displayable", + "mode": "expression", + "group": "bar" + }, + { + "name": "top_bar", + "type": "displayable", + "mode": "expression", + "group": "bar" + }, + { + "name": "bottom_bar", + "type": "displayable", + "mode": "expression", + "group": "bar" + }, + { + "name": "base_bar", + "type": "displayable", + "mode": "expression", + "group": "bar" + }, + { + "name": "thumb", + "type": "displayable or None", + "mode": "expression", + "group": "bar" + }, + { + "name": "thumb_align", + "type": "float", + "mode": "number", + "group": "bar" + }, + { + "name": "thumb_shadow", + "type": "displayable or None", + "mode": "expression", + "group": "bar" + }, + { + "name": "thumb_offset", + "type": "int or tuple of (int, int)", + "mode": "expression", + "group": "bar" + }, + { + "name": "mouse_alt", + "type": "string", + "mode": "text", + "group": "bar" + }, + { + "name": "unscrollable", + "type": "string or None", + "mode": "expression", + "group": "bar" + }, + { + "name": "keyboard_focus_alt", + "type": "boolean", + "mode": "boolean", + "group": "bar" + }, + { + "name": "spacing", + "type": "int", + "mode": "number", + "group": "box" + }, + { + "name": "first_spacing", + "type": "int", + "mode": "number", + "group": "box" + }, + { + "name": "box_reverse", + "type": "boolean", + "mode": "boolean", + "group": "box" + }, + { + "name": "box_wrap", + "type": "boolean", + "mode": "boolean", + "group": "box" + }, + { + "name": "box_wrap_spacing", + "type": "int", + "mode": "number", + "group": "box" + }, + { + "name": "box_align", + "type": "float or None", + "mode": "expression", + "group": "box" + }, + { + "name": "order_reverse", + "type": "boolean", + "mode": "boolean", + "group": "box" + }, + { + "name": "box_justify", + "type": "boolean or \"first\" or \"all\"", + "mode": "expression", + "group": "box" + }, + { + "name": "spacing_alt", + "type": "int", + "mode": "number", + "group": "grid" + }, + { + "name": "xspacing", + "type": "int or None", + "mode": "expression", + "group": "grid" + }, + { + "name": "yspacing", + "type": "int or None", + "mode": "expression", + "group": "grid" + }, + { + "name": "fit_first", + "type": "boolean or \"width\" or \"height\"", + "mode": "expression", + "group": "fixed" + }, + { + "name": "xfit", + "type": "boolean", + "mode": "boolean", + "group": "fixed" + }, + { + "name": "yfit", + "type": "boolean", + "mode": "boolean", + "group": "fixed" + }, + { + "name": "left_margin", + "type": "int", + "mode": "number", + "group": "margin" + }, + { + "name": "right_margin", + "type": "int", + "mode": "number", + "group": "margin" + }, + { + "name": "xmargin", + "type": "int", + "mode": "number", + "group": "margin" + }, + { + "name": "top_margin", + "type": "int", + "mode": "number", + "group": "margin" + }, + { + "name": "bottom_margin", + "type": "int", + "mode": "number", + "group": "margin" + }, + { + "name": "ymargin", + "type": "int", + "mode": "number", + "group": "margin" + }, + { + "name": "margin", + "type": "tuple", + "mode": "expression", + "group": "margin" + } + ] +}; diff --git a/static/js/characters-core.js b/static/js/characters-core.js new file mode 100644 index 0000000..38e0735 --- /dev/null +++ b/static/js/characters-core.js @@ -0,0 +1,206 @@ +/* Typed Character export. User Python is stored as text, never executed. */ +(function () { + 'use strict'; + const catalog = window.ESDocCharacterCatalog; + const definitions = [ + ['kind','Основа персонажа','expression','identity','adv / nvl / свой персонаж'], + ['dynamic','Динамическое имя','boolean','identity','Имя вычисляется перед каждой репликой.'], + ['image','Тег изображения','text','identity','Связывает персонажа с изображением и side image.'], + ['voice_tag','Тег голоса','text','identity','Для отдельных настроек голоса персонажа.'], + ['who_prefix','Префикс имени','text','who','Текст перед именем.'], + ['who_suffix','Суффикс имени','text','who','Текст после имени.'], + ['what_prefix','Префикс реплики','text','what','Текст перед репликой.'], + ['what_suffix','Суффикс реплики','text','what','Текст после реплики.'], + ['who_style','Стиль имени','text','who','Имя стиля из проекта.'], + ['what_style','Стиль реплики','text','what','Имя стиля из проекта.'], + ['window_style','Стиль окна','text','window','Имя стиля из проекта.'], + ['namebox_style','Стиль плашки имени','text','namebox','Для стандартного GUI Ren\'Py.'], + ['condition','Условие показа','text','behavior','Python-выражение в строке, например has_met_eileen.'], + ['interact','Ожидание ввода','boolean','behavior','Останавливает игру на реплике.'], + ['advance','Разрешить переход дальше','boolean','behavior','Разрешает клик, пропуск и автопереход.'], + ['callback','Обработчик событий','expression','behavior','Функция или список функций, без кавычек.'], + ['mode','Режим','text','behavior','Режим взаимодействия, например say.'], + ['retain','Сохранять экран','boolean','behavior','Обычно используется с речевыми облачками.'], + ['ctc','Индикатор продолжения','expression','ctc','Displayable, например Text("…").'], + ['ctc_pause','Индикатор при паузе','expression','ctc','Для тегов {p} и {w}.'], + ['ctc_timedpause','Индикатор временной паузы','expression','ctc','Для {p=} и {w=}; Null() отключает индикатор.'], + ['ctc_position','Положение индикатора','text','ctc','nestled / nestled-close / fixed / screen-variable'], + ['screen','Экран диалога','text','screen','Например say или bubble.'], + ['show_layer','Слой экрана','text','screen','Слой для экрана диалога.'], + ['show_function','Функция показа','expression','engine','Пользовательская функция показа.'], + ['predict_function','Функция предсказания','expression','engine','Пользовательская функция предзагрузки.'], + ['slow','Постепенный вывод','boolean','engine','Разрешает медленный вывод текста.'], + ['slow_abortable','Прерывать печать кликом','boolean','engine','Устаревший алиас what_slow_abortable.'], + ['afm','Автопереход','boolean','engine','Учитывать автоматический переход.'], + ['all_at_once','Вся реплика сразу','boolean','engine','Выводит все сегменты реплики одновременно.'], + ['with_none','Переход with None','boolean','engine','Поведение перехода после реплики.'], + ['type','Тип реплики','text','engine','Тип для истории и внутренних обработчиков.'], + ['warp','Пропуск при warp','boolean','engine','Поведение при быстром переходе по сценарию.'], + ['statement_name','Имя оператора','text','engine','Внутреннее имя оператора.'], + ]; + const args = definitions.map(([key,label,mode,group,help]) => ({key,label,mode,group,help,type: mode})); + const labels = { + color:'Цвет', size:'Размер', font:'Шрифт', bold:'Полужирный', italic:'Курсив', underline:'Подчёркивание', strikethrough:'Перечёркивание', + kerning:'Межбуквенный интервал', outlines:'Обводки и тени', justify:'По ширине', textalign:'Выравнивание текста', + line_spacing:'Межстрочный интервал', line_leading:'Отступ перед строкой', line_overlap_split:'Перекрытие строк', + slow_cps:'Скорость печати', slow_cps_multiplier:'Множитель скорости', background:'Фон', padding:'Внутренние отступы', + xpos:'Позиция X', ypos:'Позиция Y', xalign:'Выравнивание X', yalign:'Выравнивание Y', xsize:'Ширина', ysize:'Высота', + }; + const styleHelp = { + color:'HEX: #RRGGBB или #RRGGBBAA с прозрачностью. Выбор цвета задаёт непрозрачный цвет.', + size:'Пиксели в сцене игры. Здесь размер пересчитан из 1920 × 1080.', + font:'Путь к шрифту в game, например fonts/my-font.ttf. Файл для проверки можно выбрать под сценой.', + outlines:'Список обводок: [(толщина, "цвет", смещение X, смещение Y)]. Например [(2, "#000", 0, 1)]. Размеры в пикселях сцены.', + slow_cps:'Символов в секунду. 0 выводит текст сразу. Проверьте результат кнопкой «Проиграть реплику».', + slow_cps_multiplier:'Множитель скорости печати: 1 оставляет её без изменения.', + padding:'Отступы в пикселях: (по горизонтали, по вертикали) или (слева, сверху, справа, снизу).', + textalign:'0 — слева, 0.5 — по центру, 1 — справа.', + xpos:'Положение по X: целое число — пиксели, дробное — доля ширины сцены.', + ypos:'Положение по Y: целое число — пиксели, дробное — доля высоты сцены.', + xalign:'Выравнивание по X: 0 — слева, 0.5 — по центру, 1 — справа.', + yalign:'Выравнивание по Y: 0 — сверху, 0.5 — по центру, 1 — снизу.', + kerning:'Межбуквенный интервал в пикселях сцены.', + line_spacing:'Дополнительный интервал между строками в пикселях сцены.', + background:'Цвет или ресурс фона, например "#18232e" или "gui/textbox.png".', + }; + const typeHelp = { + text:'Текстовое значение. Кавычки в коде будут добавлены автоматически.', + number:'Числовое значение. Для переменной или выражения раскройте «Параметр / Python».', + boolean:'Оставьте «По умолчанию», чтобы сохранить поведение игры.', + expression:'Выражение Python: переменная, функция, список или кортеж. Оно попадёт в код без кавычек.', + }; + const styleFields = []; + for (const prefix of ['who','what','window','namebox']) { + for (const prop of catalog.styles) { + const applicable = ['position', ...(prefix === 'who' || prefix === 'what' ? ['text'] : ['window','margin'])]; + if (!applicable.includes(prop.group)) continue; + const help = styleHelp[prop.name] || typeHelp[prop.mode]; + styleFields.push({key: prefix + '_' + prop.name, label: labels[prop.name] || prop.name, mode: prop.mode, type: prop.type, group: prefix, styleGroup: prop.group, help, doc: catalog.source + '#style-property-' + prop.name}); + } + } + const fields = [...args, ...styleFields]; + const byKey = new Map(fields.map(item => [item.key, item])); + const keywords = new Set('False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case'.split(' ')); + const identifier = value => typeof value === 'string' && /^[\p{ID_Start}_][\p{ID_Continue}_]*$/u.test(value) && !keywords.has(value); + const quote = value => JSON.stringify(String(value)).replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'); + + // This is a structural check, not a Python interpreter or full parser. + function expression(value) { + const source = value.trim(); + if (!source || source.length > 20000) throw new Error('Введите Python-выражение (до 20 000 символов).'); + const stack = []; let string = '', escaped = false; + for (const ch of source) { + if (string) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === string) string = ''; + else if (ch === '\n' || ch === '\r') throw new Error('Используйте \\n внутри строки.'); + continue; + } + if (ch === '"' || ch === "'") string = ch; + else if ('([{'.includes(ch)) stack.push(ch); + else if (')]}'.includes(ch)) { + if (stack.pop() !== ({')':'(',']':'[','}':'{'})[ch]) throw new Error('Проверьте скобки в Python-выражении.'); + } else if (ch === ';' || ch === '#' || ((ch === '\n' || ch === '\r') && !stack.length)) { + throw new Error('Введите одно выражение, без комментариев и операторов через точку с запятой.'); + } + } + if (string || stack.length || /[+\-*/=,:.]$/.test(source)) throw new Error('Завершите Python-выражение: проверьте кавычки и скобки.'); + return source; + } + function serialize(entry) { + if (!entry || typeof entry.value !== 'string') throw new Error('Введите значение.'); + if (entry.value.length > 20000) throw new Error('Максимум 20 000 символов в одном значении.'); + if (entry.mode === 'text') return quote(entry.value); + if (entry.mode === 'boolean') { + if (!['True','False','None'].includes(entry.value)) throw new Error('Выберите True, False или None.'); + return entry.value; + } + if (entry.mode === 'number') { + const raw = entry.value.trim(); + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(raw) || !Number.isFinite(Number(raw))) throw new Error('Введите конечное число, дробную часть отделите точкой.'); + if (/^[+-]?0\d+$/.test(raw) && Number(raw) !== 0) throw new Error('Уберите ведущие нули из целого числа.'); + return raw.replace(/^\+/, ''); + } + if (entry.mode === 'expression') return expression(entry.value); + throw new Error('Неизвестный тип значения.'); + } + function compile(state) { + const errors = [], parts = []; + if (!identifier(state.variable)) errors.push({key:'variable', message:'Введите имя переменной Python: буквы, цифры и _, без пробелов; не ключевое слово.'}); + try { + if (state.nameMode === 'none') parts.push('None'); + else if (state.nameMode === 'text') parts.push(quote(state.name)); + else if (state.nameMode === 'expression') parts.push(expression(state.name)); + else if (state.nameMode !== 'inherit') throw new Error('Выберите способ задания имени.'); + } catch (error) { errors.push({key:'name',message:error.message}); } + for (const [key, entry] of Object.entries(state.values || {})) { + try { + if (!identifier(key) || ['name','__proto__','constructor','prototype'].includes(key)) throw new Error('Недопустимое имя или повторный параметр name.'); + parts.push(key + '=' + serialize(entry)); + } catch (error) { errors.push({key,message:error.message}); } + } + const call = parts.length < 2 ? 'Character(' + parts.join(', ') + ')' : 'Character(\n ' + parts.join(',\n ') + ',\n)'; + return {code: errors.length ? '' : 'define ' + state.variable + ' = ' + call, errors}; + } + function project(state) { return JSON.stringify({tool:'es-doc-character', version:1, state}, null, 2); } + function literal(source) { + // Only scalar literals and nested lists/tuples. No names, calls or eval. + let at = 0, depth = 0; + const skip = () => { while (/\s/.test(source[at] || '') && at < source.length) at++; }; + const parse = () => { + skip(); if (++depth > 32) throw new Error('Too deep'); + const ch = source[at]; let value; + if (ch === '[' || ch === '(') { + at++; const close = ch === '[' ? ']' : ')'; value = []; skip(); + while (source[at] !== close) { + value.push(parse()); skip(); + if (source[at] !== ',') break; + at++; skip(); + } + if (source[at++] !== close) throw new Error('Incomplete literal'); + } else if (ch === '"' || ch === "'") { + const quote = source[at++]; value = ''; let ended = false; + while (at < source.length) { + let next = source[at++]; + if (next === quote) { ended = true; break; } + if (next === '\\') { + next = source[at++]; + if (next === 'u' || next === 'x') { + const count = next === 'u' ? 4 : 2, hex = source.slice(at, at + count); + if (!new RegExp('^[0-9a-f]{' + count + '}$','i').test(hex)) throw new Error('Escape'); + value += String.fromCharCode(parseInt(hex,16)); at += count; continue; + } + value += ({n:'\n',r:'\r',t:'\t',b:'\b',f:'\f',"'":"'",'"':'"','\\':'\\'})[next] ?? ('\\' + next); + } else value += next; + } + if (!ended) throw new Error('String'); + } else { + const match = /^(True|False|None|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/i.exec(source.slice(at)); + if (!match) throw new Error('Not a literal'); + at += match[0].length; + value = ({True:true,False:false,None:null})[match[0]]; + if (value === undefined) { value = Number(match[0]); if (!Number.isFinite(value)) throw new Error('Number'); } + } + depth--; return value; + }; + try { const value = parse(); skip(); return at === source.length ? {ok:true,value} : {ok:false}; } catch (_) { return {ok:false}; } + } + function importProject(source) { + if (typeof source !== 'string' || source.length > 1000000) throw new Error('Проект слишком большой (максимум 1 МБ).'); + let data; + try { data = JSON.parse(source); } catch (_) { throw new Error('Не удалось прочитать JSON. Выберите проект, сохранённый этим инструментом.'); } + if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Неподдерживаемый формат проекта.'); + const s = data.state; + if (data.tool !== 'es-doc-character' || data.version !== 1 || !s || !['text','none','inherit','expression'].includes(s.nameMode) + || ['variable','name','sample'].some(key => typeof s[key] !== 'string' || s[key].length > 20000) + || !s.values || Array.isArray(s.values) || typeof s.values !== 'object' || Object.keys(s.values).length > 500) throw new Error('Неподдерживаемый формат проекта.'); + const values = Object.create(null); + for (const [key,value] of Object.entries(s.values)) { + if (!identifier(key) || ['name','__proto__','constructor','prototype'].includes(key) || !value || !['text','boolean','number','expression'].includes(value.mode) || typeof value.value !== 'string' || value.value.length > 20000) throw new Error('Некорректный параметр в проекте: ' + key); + values[key] = {mode:value.mode,value:value.value}; + } + return {variable:s.variable,name:s.name,nameMode:s.nameMode,sample:s.sample,values}; + } + window.ESDocCharacters = {fields, args, field:key => byKey.get(key), identifier, quote, expression, serialize, literal, compile, project, importProject}; +})(); diff --git a/static/js/characters.js b/static/js/characters.js new file mode 100644 index 0000000..49391be --- /dev/null +++ b/static/js/characters.js @@ -0,0 +1,452 @@ +(function () { + 'use strict'; + window.__esdocCharactersCleanup?.(); + const app = document.getElementById('characters-app'); + if (!app) return; + const c = window.ESDocCharacters, get = id => document.getElementById('character-' + id); + const controller = new AbortController(), on = (el,event,fn) => el.addEventListener(event,fn,{signal:controller.signal}); + const defaults = () => ({variable:'e',name:'Семён',nameMode:'text',sample:'Я говорю так! Добро пожаловать в «Совёнок».',values:{}}); + const clone = s => JSON.parse(JSON.stringify(s)); + const storageKey = 'es-doc-character-draft-v1'; + const rows = new Map(), undos = [], redos = [], fontFaces = new Map(), fontRequests = new Map(), fonts = new Map(); + let state = defaults(), disposed = false, timer, typingTimer, committed, backgroundUrl, backgroundRequest = 0, lastField; + const downloads = new Set(); + const status = message => { if (!disposed) get('status').textContent = message; }; + try { + const saved = localStorage.getItem(storageKey); + if (saved) { state = c.importProject(saved); get('storage-note').textContent = 'Черновик восстановлен из этого браузера.'; } + } catch (_) { get('storage-note').textContent = 'Не удалось прочитать черновик. Настройки доступны до закрытия страницы; сохраните их в файл.'; } + committed = clone(state); + const remember = () => { + clearTimeout(timer); + if (JSON.stringify(committed) !== JSON.stringify(state)) { + undos.push(committed); if (undos.length > 64) undos.shift(); + committed = clone(state); redos.length = 0; + } + get('undo').disabled = undos.length === 0; get('redo').disabled = redos.length === 0; + }; + const persist = () => { + try { localStorage.setItem(storageKey,c.project(state)); get('storage-note').textContent = 'Черновик сохранён в этом браузере.'; } + catch (_) { get('storage-note').textContent = 'Браузер не разрешил сохранить черновик. Используйте «Сохранить настройки».'; } + }; + const edited = (immediate = false) => { + render(); persist(); clearTimeout(timer); + if (immediate) remember(); else timer = setTimeout(remember,450); + }; + const element = (tag,className,text) => { + const el = document.createElement(tag); if (className) el.className = className; + if (text !== undefined) el.textContent = text; return el; + }; + const option = (select,value,label) => { const el = element('option','',label); el.value = value; select.append(el); }; + function addField(key, container, extra = false) { + if (rows.has(key)) return rows.get(key); + const field = c.field(key) || {key,label:key,mode:'expression',help:'Параметр вашего проекта. Значение передаётся в Character без изменений.'}; + const row = element('div','character-field' + (extra ? ' character-parameter' : '')); + const head = element('div','character-field-head'), label = element('label','',field.label); + label.htmlFor = 'character-value-' + key; + const enableLabel = element('label','character-enable'), enabled = element('input'); enabled.type = 'checkbox'; enabled.id = 'character-enabled-' + key; + enabled.setAttribute('aria-label','Передать значение явно: ' + key); enableLabel.append(enabled,document.createTextNode('Передать значение явно')); head.append(label); + const meta = element('div','character-field-key'); + if (field.doc) { const link = element('a','',key); link.href = field.doc; link.title = 'Справка ' + key; meta.append(link); } else meta.textContent = key; + const controls = element('div','character-field-value'), mode = element('select','character-mode'); + mode.id = 'character-mode-' + key; + mode.setAttribute('aria-label','Тип значения ' + key); + option(mode,field.mode,({text:'Текст',number:'Число',boolean:'Да / нет',expression:'Python'})[field.mode]); + if (field.mode !== 'expression') option(mode,'expression','Python'); + else { option(mode,'text','Текст'); option(mode,'number','Число'); option(mode,'boolean','Да / нет'); } + const holder = element('div','character-value'), error = element('p','character-error'); + error.id = 'character-error-' + key; error.hidden = true; + const help = element('p','character-field-help',field.help); help.id = 'character-help-' + key; + const advanced = element('details','character-field-options'), summary = element('summary','','Параметр / Python'); + summary.setAttribute('aria-label','Параметр и тип значения: ' + field.label + ' / ' + key); + const reset = element('button','character-button',extra ? 'Удалить поле' : 'Сбросить значение'); reset.type = 'button'; + reset.id = 'character-remove-' + key; reset.setAttribute('aria-label',(extra ? 'Удалить поле: ' : 'Сбросить значение: ') + key); + const modeLabel = element('label','','Тип значения'); modeLabel.htmlFor = mode.id; + advanced.append(summary,meta,enableLabel,modeLabel,mode); + advanced.append(element('p','character-field-help','Ввод включает параметр автоматически. Этот переключатель позволяет передать пустую строку явно. «Сбросить значение» возвращает наследование.')); + if (extra) head.append(reset); else advanced.append(reset); + const colorPicker = key.endsWith('_color') ? element('input','character-color') : null; + if (colorPicker) { + colorPicker.type = 'color'; colorPicker.id = 'character-color-' + key; + colorPicker.setAttribute('aria-label','Выбрать цвет: ' + field.label + ' / ' + key); + colorPicker.title = 'Выбрать непрозрачный цвет'; + on(colorPicker,'input',() => { input.value = colorPicker.value; state.values[key] = {mode:'text',value:input.value}; edited(); }); + on(colorPicker,'change',remember); + } + let input; + const buildInput = () => { + const oldFocus = document.activeElement === input; + input = element(mode.value === 'boolean' ? 'select' : 'input'); + input.id = 'character-value-' + key; + if (mode.value === 'boolean') { + option(input,'','По умолчанию'); option(input,'True','Да / True'); option(input,'False','Нет / False'); option(input,'None','None'); + } else { + input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false; input.maxLength = 20000; + input.placeholder = mode.value === 'expression' ? (key.endsWith('outlines') ? '[(2, "#000", 0, 0)]' : 'По умолчанию') : 'По умолчанию'; + if (mode.value === 'number') input.inputMode = 'decimal'; + } + input.setAttribute('aria-describedby',help.id + ' ' + error.id); + holder.replaceChildren(input); + on(input,mode.value === 'boolean' ? 'change' : 'input', () => { + if (!input.value && (mode.value === 'boolean' || !(advanced.open && enabled.checked))) delete state.values[key]; + else state.values[key] = {mode:mode.value,value:input.value}; + enabled.checked = Boolean(state.values[key]); edited(mode.value === 'boolean'); + }); + on(input,'change',remember); + if (oldFocus) input.focus(); + }; + on(enabled,'change',() => { + if (enabled.checked) { if (mode.value === 'boolean' && !input.value) input.value = 'True'; state.values[key] = {mode:mode.value,value:input.value}; } + else delete state.values[key]; + edited(true); + }); + on(mode,'change',() => { + const previous = input.value; buildInput(); + input.value = mode.value === 'boolean' ? (['True','False','None'].includes(previous) ? previous : 'True') : previous; + state.values[key] = {mode:mode.value,value:input.value}; enabled.checked = true; edited(true); + }); + on(reset,'click',() => { + remember(); + delete state.values[key]; + if (extra) { row.remove(); rows.delete(key); get('add-property').focus(); } + else { mode.value = field.mode; buildInput(); input.value = ''; input.focus(); } + edited(true); status(extra ? 'Поле удалено. Значение можно вернуть кнопкой «Отменить».' : 'Значение сброшено: используется стиль игры.'); + }); + buildInput(); controls.append(holder); if (colorPicker) controls.append(colorPicker); + row.append(head,controls,help,advanced,error); container.append(row); + const record = {row,field,mode,enabled,error,colorPicker,get input() {return input;},sync() { + const entry = state.values[key]; const nextMode = entry?.mode || field.mode; + if (![...mode.options].some(item => item.value === nextMode)) option(mode,nextMode,({text:'Текст',number:'Число',boolean:'Да / нет',expression:'Python'})[nextMode]); + if (mode.value !== nextMode) { mode.value = nextMode; buildInput(); } + enabled.checked = Boolean(entry); input.value = entry?.value ?? ''; + if (entry && (nextMode !== field.mode || entry.value === '')) advanced.open = true; + }}; + rows.set(key,record); record.sync(); return record; + } + const groups = { + quick:['who_color','who_size','what_color','what_size','what_slow_cps'], + identity:['kind','dynamic'], + who:['who_font','who_bold','who_italic','who_underline'], + what:['what_font','what_bold','what_italic','what_justify'], + behavior:['image','voice_tag','condition','interact','advance','callback','retain','mode'], + screen:['screen','show_layer','ctc','ctc_pause','ctc_timedpause','ctc_position'], + engine:c.args.filter(field => field.group === 'engine').map(field => field.key), + }; + const quickLabels = {who_color:'Цвет имени',who_size:'Размер имени',what_color:'Цвет реплики',what_size:'Размер реплики'}; + for (const [group,keys] of Object.entries(groups)) for (const key of keys) { + const record = addField(key,get(group + '-fields')); + if (quickLabels[key]) record.row.querySelector('.character-field-head > label').textContent = quickLabels[key]; + } + const baselineKeys = new Set(rows.keys()); + function syncForm() { + get('variable').value = state.variable; get('name').value = state.name; + get('name-mode').value = state.nameMode; get('sample').value = state.sample; + if (state.nameMode !== 'text' || state.values.kind || state.values.dynamic) get('identity-options').open = true; + for (const key of Object.keys(state.values)) if (!rows.has(key)) addField(key,get('extra-fields'),true); + for (const [key,row] of rows) { + if (!baselineKeys.has(key) && !Object.hasOwn(state.values,key)) { row.row.remove(); rows.delete(key); } + else { row.sync(); if (Object.hasOwn(state.values,key)) reveal(row.row); } + } + render(); + } + function configured(key) { + const entry = state.values[key]; if (!entry) return {ok:false}; + if (entry.mode === 'text') return {ok:true,value:entry.value}; + return c.literal(entry.value); + } + const previewed = new Set(); + const value = (key,fallback) => { + const result = configured(key); + if (!result.ok) return fallback; + previewed.add(key); return result.value; + }; + const color = candidate => { + if (typeof candidate === 'string' && /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate)) return candidate; + if (Array.isArray(candidate) && [3,4].includes(candidate.length) && candidate.every(v => typeof v === 'number' && v >= 0 && v <= 255)) { + // Ren'Py tuples use byte channels; alpha follows the same rule. + return '#' + candidate.map(v => Math.round(v).toString(16).padStart(2,'0')).join(''); + } + return null; + }; + const number = (candidate,min = -Infinity,max = Infinity) => typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= min && candidate <= max; + const px = n => 'calc(' + (n / 19.2) + 'cqw * var(--character-preview-scale))'; + function paintText(target,prefix) { + target.removeAttribute('style'); + const use = (prop,fn) => { + const key = prefix + '_' + prop, result = configured(key); + if (result.ok && fn(result.value) !== false) previewed.add(key); + }; + use('color',v => { const parsed = color(v); if (!parsed) return false; target.style.color = parsed; }); + use('size',v => { if (!number(v,1,400)) return false; target.style.fontSize = px(v); }); + use('bold',v => { if (typeof v !== 'boolean') return false; target.style.fontWeight = v ? '700' : '400'; }); + use('italic',v => { if (typeof v !== 'boolean') return false; target.style.fontStyle = v ? 'italic' : 'normal'; }); + const decoration = []; + for (const prop of ['underline','strikethrough']) use(prop,v => { if (typeof v !== 'boolean') return false; if (v) decoration.push(prop === 'underline' ? 'underline' : 'line-through'); }); + target.style.textDecoration = decoration.join(' ') || 'none'; + use('kerning',v => { if (!number(v,-100,500)) return false; target.style.letterSpacing = px(v); }); + use('line_spacing',v => { if (!number(v,-100,500)) return false; const size = value(prefix + '_size',32); target.style.lineHeight = px((number(size,1,400) ? size : 32) * 1.5 + v); }); + use('line_leading',v => { if (!number(v,-100,500)) return false; target.style.paddingTop = px(v); }); + use('justify',v => { if (typeof v !== 'boolean') return false; target.style.textAlign = v ? 'justify' : 'left'; }); + use('textalign',v => { if (!number(v,0,1)) return false; target.style.textAlign = v < .25 ? 'left' : v > .75 ? 'right' : 'center'; }); + use('outlines',v => { + if (!Array.isArray(v) || v.length > 16 || v.some(item => !Array.isArray(item) || !number(item[0],0,30) || !color(item[1]) || !number(item[2],-100,100) || !number(item[3],-100,100))) return false; + const shadows = []; + for (const [radius,tint,x,y] of v) { + const offsets = radius === 0 ? [[0,0]] : [[-radius,0],[radius,0],[0,-radius],[0,radius],[-radius,-radius],[radius,radius],[-radius,radius],[radius,-radius]]; + for (const [dx,dy] of offsets) shadows.push(px(x+dx) + ' ' + px(y+dy) + ' 0 ' + color(tint)); + } + target.style.textShadow = shadows.join(',') || 'none'; + }); + for (const axis of ['x','y']) { + use(axis + 'offset',v => { if (!number(v,-1920,1920)) return false; target.style.position = 'relative'; target.style[axis === 'x' ? 'left' : 'top'] = px(v); }); + use(axis + 'size',v => { if (!number(v,0,1920)) return false; target.style[axis === 'x' ? 'width' : 'height'] = px(v); }); + } + if (fonts.has(prefix)) target.style.fontFamily = '"' + fonts.get(prefix) + '"'; + } + function previewText(target,text) { + // A small safe subset of Ren'Py tags; all content becomes DOM text. + target.replaceChildren(); const stack = [target]; + const tokens = String(text).split(/(\{\{|\{[^{}]*\})/g); + for (const token of tokens) { + if (token === '{{') { stack.at(-1).append(document.createTextNode('{')); continue; } + const match = /^\{(\/)?(b|i|u|s|color|size)(?:=([^}]+))?\}$/.exec(token); + if (match && match[1]) { if (stack.length > 1) stack.pop(); continue; } + if (match) { + const span = element('span'), tag = match[2], arg = match[3]; + if (tag === 'b') span.style.fontWeight = '700'; + if (tag === 'i') span.style.fontStyle = 'italic'; + if (tag === 'u' || tag === 's') span.style.textDecoration = tag === 'u' ? 'underline' : 'line-through'; + if (tag === 'color' && color(arg)) span.style.color = color(arg); + if (tag === 'size' && /^\d+$/.test(arg) && number(Number(arg),1,400)) span.style.fontSize = px(Number(arg)); + stack.at(-1).append(span); if (stack.length < 32) stack.push(span); + } else stack.at(-1).append(document.createTextNode(token.replace(/\[\[/g,'['))); + } + } + function paintPreview() { + clearTimeout(typingTimer); previewed.clear(); + const who = get('preview-who'), what = get('preview-what'); + paintText(who,'who'); paintText(what,'what'); + const dynamic = value('dynamic',false); + let name = state.name; + if (state.nameMode === 'none') name = ''; + else if (state.nameMode === 'expression' || state.nameMode === 'inherit' || dynamic === true) name = get('preview-name').value; + get('namebox').hidden = state.nameMode === 'none'; + previewText(who,value('who_prefix','') + name + value('who_suffix','')); + previewText(what,value('what_prefix','') + state.sample + value('what_suffix','')); + const box = get('dialogue-window'), namebox = get('namebox'); box.removeAttribute('style'); namebox.removeAttribute('style'); + for (const [prefix,target] of [['window',box],['namebox',namebox]]) { + const bg = configured(prefix + '_background'); + if (bg.ok && color(bg.value)) { target.style.background = color(bg.value); previewed.add(prefix + '_background'); } + const padding = configured(prefix + '_padding'); + if (padding.ok && Array.isArray(padding.value) && [2,4].includes(padding.value.length) && padding.value.every(v => number(v,0,500))) { + const v = padding.value; + const cssOrder = v.length === 2 ? [v[1],v[0]] : [v[1],v[2],v[3],v[0]]; + target.style.padding = cssOrder.map(px).join(' '); previewed.add(prefix + '_padding'); + } + } + // These settings affect only the replay button, never automatic input changes. + for (const key of ['what_slow_cps','what_slow_cps_multiplier','slow','all_at_once','what_slow_abortable','slow_abortable']) { + const result = configured(key); + if (result.ok && (typeof result.value === 'boolean' || result.value === null || number(result.value,0,10000))) previewed.add(key); + } + const limits = []; + for (const key of Object.keys(state.values)) { + if (previewed.has(key)) continue; + if ((key === 'who_font' || key === 'what_font') && fonts.has(key.split('_')[0])) continue; + limits.push(key); + } + if (state.nameMode === 'expression') limits.unshift('name / Python'); + if (state.nameMode === 'inherit') limits.unshift('name / наследование'); + if (/\{(?!\{|\/?(?:b|i|u|s|color|size)(?:[}=]))|(? element('li','',key))); + } + function render() { + get('name-control').hidden = state.nameMode === 'none' || state.nameMode === 'inherit'; + get('name-control').querySelector('label').textContent = state.nameMode === 'expression' ? 'Python-выражение / функция имени' : 'Имя'; + for (const key of ['variable','name']) { get(key).removeAttribute('aria-invalid'); get(key + '-error').hidden = true; } + for (const [key,row] of rows) { + row.enabled.checked = Boolean(state.values[key]); row.error.hidden = true; row.input.removeAttribute('aria-invalid'); + if (row.colorPicker) { + row.colorPicker.hidden = row.mode.value !== 'text'; + const raw = state.values[key]?.value || (key.startsWith('who_') ? '#e2c778' : '#f5ecd7'); + if (/^#[\da-f]{3,4}$/i.test(raw)) row.colorPicker.value = '#' + raw.slice(1,4).split('').map(ch => ch + ch).join(''); + else if (/^#[\da-f]{6}(?:[\da-f]{2})?$/i.test(raw)) row.colorPicker.value = raw.slice(0,7); + } + } + const result = c.compile(state); + get('code').value = result.code || 'Исправьте значения в отмеченных полях, чтобы получить код.'; + get('code').rows = Math.max(4,Math.min(13,result.code.split('\n').length + 1)); + get('copy').disabled = get('download').disabled = result.errors.length > 0; + get('validation').hidden = result.errors.length === 0; + get('validation').textContent = result.errors.length ? 'Ошибок в настройках: ' + result.errors.length + '. Исправьте отмеченные поля.' : ''; + get('fix-error').hidden = result.errors.length === 0; + get('fix-error').dataset.key = result.errors[0]?.key || ''; + get('usage').textContent = (c.identifier(state.variable) ? state.variable : 'e') + ' "Привет!"'; + for (const error of result.errors) { + const row = rows.get(error.key), input = row?.input || get(error.key), hint = row?.error || get(error.key + '-error'); + input?.setAttribute('aria-invalid','true'); + if (hint) { hint.hidden = false; hint.textContent = error.message; } + } + get('extra-count').textContent = [...rows.keys()].filter(key => !baselineKeys.has(key)).length || ''; + paintPreview(); + } + for (const [id,key] of [['variable','variable'],['name','name'],['sample','sample']]) { + on(get(id),'input',() => { state[key] = get(id).value; edited(); }); on(get(id),'change',remember); + } + on(get('name-mode'),'change',() => { state.nameMode = get('name-mode').value; edited(true); }); + on(get('settings'),'submit',event => event.preventDefault()); + on(get('undo'),'click',() => { + remember(); if (!undos.length) return; + redos.push(clone(state)); state = undos.pop(); committed = clone(state); persist(); syncForm(); + get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; status('Изменение отменено.'); + }); + on(get('redo'),'click',() => { + if (!redos.length) return; + undos.push(clone(state)); state = redos.pop(); committed = clone(state); persist(); syncForm(); + get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; status('Изменение восстановлено.'); + }); + on(get('reset'),'click',() => { remember(); state = defaults(); syncForm(); persist(); remember(); status('Настройки сброшены. Кнопка «Отменить» восстановит персонажа.'); }); + on(get('copy'),'click',async () => { + const code = c.compile(state).code; if (!code) return; + try { await navigator.clipboard.writeText(code); status('Определение скопировано.'); } + catch (_) { if (!disposed) { get('code').focus(); get('code').select(); status('Код выделен. Нажмите Ctrl+C или выберите «Копировать» в меню.'); } } + }); + function download(name,content,type) { + const url = URL.createObjectURL(new Blob([content],{type})); downloads.add(url); + const link = element('a'); link.href = url; link.download = name; document.body.append(link); link.click(); link.remove(); + setTimeout(() => { URL.revokeObjectURL(url); downloads.delete(url); },1000); + } + on(get('download'),'click',() => { const code = c.compile(state).code; if (code) { download(state.variable + '.rpy',code + '\n','text/plain;charset=utf-8'); status('Определение сохранено в .rpy.'); } }); + on(get('save-project'),'click',() => { download((c.identifier(state.variable) ? state.variable : 'character') + '.character.json',c.project(state),'application/json'); status('Настройки сохранены. Их можно открыть в этом конструкторе.'); }); + on(get('load-project'),'click',() => get('project-file').click()); + on(get('project-file'),'change',async () => { + const file = get('project-file').files[0]; if (!file) return; + try { + if (file.size > 1000000) throw new Error('Выберите JSON размером до 1 МБ.'); + const loaded = c.importProject(await file.text()); if (disposed) return; + remember(); state = loaded; syncForm(); persist(); remember(); status('Настройки открыты. Предыдущие можно вернуть кнопкой «Отменить».'); + } catch (error) { status(error.message); } + finally { if (!disposed) get('project-file').value = ''; } + }); + const normalizeSearch = text => text.toLowerCase().replace(/ё/g,'е').replace(/[а-я]{4,}/g,word => word.replace(/(?:ами|ями|ого|ему|ой|ый|ая|ое|ов|ам|ям|ах|ях|ы|и|а|я|у|ю|е|о)$/,'')); + const aliases = {slow_cps:'скорость печать символ секунду',font:'шрифт путь файл',outlines:'обводка тень контур',color:'цвет прозрачность',justify:'выравнивание ширина',textalign:'выравнивание центр',size:'размер текст кегль',bold:'жирный полужирный',italic:'курсив наклон',underline:'подчеркивание'}; + const fieldScope = field => ['who','what','window','namebox'].includes(field.group) ? field.group : 'character'; + const filterProperties = () => { + const scope = get('property-scope').value, query = get('property-search').value.toLowerCase().trim(); + const words = normalizeSearch(query).split(/\s+/); + const matches = c.fields.filter(field => { + const alias = Object.entries(aliases).filter(([key]) => field.key.endsWith('_' + key)).map(([,text]) => text).join(' '); + const searchable = normalizeSearch(field.key + ' ' + field.label + ' ' + field.help + ' ' + alias); + return !query || words.every(word => searchable.includes(word)); + }); + const fields = matches.filter(field => fieldScope(field) === scope); + get('property').replaceChildren(); + for (const field of fields) option(get('property'),field.key,field.key + (field.label !== field.key ? ' / ' + field.label : '')); + get('add-property').disabled = fields.length === 0; get('property').disabled = fields.length === 0; + const elsewhere = fields.length ? null : matches.find(field => fieldScope(field) !== scope); + const scopeLabel = elsewhere && [...get('property-scope').options].find(item => item.value === fieldScope(elsewhere)).textContent; + get('property-help').textContent = fields.length ? 'Найдено: ' + fields.length + '. Выберите свойство и добавьте поле.' : elsewhere ? 'В этой области совпадений нет. Подходящий параметр есть в области «' + scopeLabel + '».' : 'Совпадений нет. Попробуйте другое название, например «скорость» или «обводка», либо добавьте свой параметр ниже.'; + get('switch-scope').hidden = !elsewhere; + if (elsewhere) { get('switch-scope').dataset.scope = fieldScope(elsewhere); get('switch-scope').textContent = 'Искать в области «' + scopeLabel + '»'; } + }; + on(get('switch-scope'),'click',() => { get('property-scope').value = get('switch-scope').dataset.scope; filterProperties(); get('property').focus(); }); + on(get('property-search'),'input',filterProperties); on(get('property-scope'),'change',filterProperties); + function addExtra(key) { + let row = rows.get(key); + if (!row) row = addField(key,get('extra-fields'),true); + reveal(row.row); + row.input.focus(); row.row.scrollIntoView({block:'nearest'}); + get('extra-count').textContent = [...rows.keys()].filter(key => !baselineKeys.has(key)).length || ''; + } + on(get('add-property'),'click',() => { const key = get('property').value; if (key) addExtra(key); }); + on(get('add-custom'),'click',() => { + const key = get('custom-key').value.trim(); + if (!c.identifier(key) || ['name','__proto__','prototype','constructor'].includes(key)) { get('custom-error').hidden = false; get('custom-error').textContent = 'Введите имя параметра Python без пробелов. Параметр name уже задаётся полем имени.'; get('custom-key').setAttribute('aria-invalid','true'); return; } + get('custom-error').hidden = true; get('custom-key').removeAttribute('aria-invalid'); addExtra(key); get('custom-key').value = ''; + }); + function reveal(target) { + for (let parent = target.parentElement; parent && parent !== app; parent = parent.parentElement) if (parent.tagName === 'DETAILS') parent.open = true; + } + function focusTarget(target) { + if (!target?.isConnected) return; + reveal(target); target.focus({preventScroll:true}); target.scrollIntoView({block:'start'}); + } + on(get('settings'),'focusin',event => { if (event.target.matches('input:not([type="checkbox"]), select')) lastField = event.target; }); + for (const button of app.querySelectorAll('[data-character-jump]')) on(button,'click',() => { + const section = button.dataset.characterJump; + focusTarget(section === 'settings' && lastField?.isConnected ? lastField : get(section + '-heading')); + }); + on(get('fix-error'),'click',() => { const key = get('fix-error').dataset.key; focusTarget(rows.get(key)?.input || get(key)); }); + on(document,'keydown',event => { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return; + // Preserve the browser's native text undo inside editors. + if (event.target.closest('input,textarea,[contenteditable="true"]')) return; + if (event.key.toLowerCase() === 'z') { event.preventDefault(); get(event.shiftKey ? 'redo' : 'undo').click(); } + if (event.key.toLowerCase() === 'y') { event.preventDefault(); get('redo').click(); } + }); + on(get('preview-name'),'input',paintPreview); + const zoom = active => { + get('stage').classList.toggle('is-zoomed',active); get('zoom').setAttribute('aria-pressed',String(active)); get('zoom').textContent = active ? 'Вся сцена' : 'Крупнее'; + }; + on(get('zoom'),'click',() => zoom(get('zoom').getAttribute('aria-pressed') !== 'true')); + zoom(true); + on(get('background-color'),'input',() => { get('stage').style.backgroundColor = get('background-color').value; }); + const clearBackground = () => { backgroundRequest++; if (backgroundUrl) URL.revokeObjectURL(backgroundUrl); backgroundUrl = null; get('stage').style.backgroundImage = ''; get('background-file').value = ''; }; + on(get('clear-background'),'click',clearBackground); + on(get('background-file'),'change',async () => { + const file = get('background-file').files[0]; if (!file) return; + const request = ++backgroundRequest; let url; + try { + if (file.size > 15000000 || !['image/png','image/jpeg','image/webp'].includes(file.type)) throw new Error('Выберите PNG, JPG или WebP размером до 15 МБ.'); + url = URL.createObjectURL(file); const image = new Image(); image.src = url; await image.decode(); + if (disposed || request !== backgroundRequest) { URL.revokeObjectURL(url); return; } + if (backgroundUrl) URL.revokeObjectURL(backgroundUrl); backgroundUrl = url; get('stage').style.backgroundImage = 'url("' + url + '")'; status('Фон добавлен только в предпросмотр.'); + } catch (error) { if (url) URL.revokeObjectURL(url); status(error.message || 'Не удалось открыть изображение.'); } + }); + for (const prefix of ['who','what']) on(get('font-' + prefix),'change',async () => { + const file = get('font-' + prefix).files[0]; if (!file) return; + const request = (fontRequests.get(prefix) || 0) + 1; fontRequests.set(prefix,request); + try { + if (file.size > 10000000 || !/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('Выберите TTF, OTF, WOFF или WOFF2 размером до 10 МБ.'); + const name = 'character-preview-' + prefix + '-' + request; + const face = new FontFace(name,await file.arrayBuffer()); await face.load(); + if (disposed || request !== fontRequests.get(prefix)) return; + const previous = fontFaces.get(prefix); if (previous) document.fonts.delete(previous); + document.fonts.add(face); fontFaces.set(prefix,face); fonts.set(prefix,name); paintPreview(); + status('Шрифт загружен для проверки. Укажите путь к нему в игре в поле ' + prefix + '_font.'); + } catch (_) { status('Не удалось загрузить шрифт. Выберите TTF, OTF, WOFF или WOFF2 размером до 10 МБ.'); } + }); + on(get('clear-fonts'),'click',() => { + for (const prefix of ['who','what']) { fontRequests.set(prefix,(fontRequests.get(prefix) || 0)+1); const face = fontFaces.get(prefix); if (face) document.fonts.delete(face); get('font-' + prefix).value = ''; } + fontFaces.clear(); fonts.clear(); paintPreview(); + }); + on(get('replay'),'click',() => { + paintPreview(); + const cps = value('what_slow_cps',30), multiplier = value('what_slow_cps_multiplier',1); + if (matchMedia('(prefers-reduced-motion: reduce)').matches || value('slow',true) === false || value('all_at_once',false) === true || !number(cps,1,10000) || !number(multiplier,.01,10000)) return; + const target = get('preview-what'), walker = document.createTreeWalker(target,NodeFilter.SHOW_TEXT), segments = []; + let node, total = 0; + while ((node = walker.nextNode())) { const characters = Array.from(node.textContent); segments.push({node,characters,start:total}); total += characters.length; node.textContent = ''; } + const started = performance.now(); + const advance = () => { + if (disposed) return; + const count = Math.min(total,Math.floor((performance.now() - started)/1000*cps*multiplier)); + for (const segment of segments) segment.node.textContent = segment.characters.slice(0,Math.max(0,count - segment.start)).join(''); + if (count < total) typingTimer = setTimeout(advance,30); else paintPreview(); + }; + advance(); + }); + on(get('stage'),'click',() => { + if (value('what_slow_abortable',value('slow_abortable',true)) !== false) paintPreview(); + }); + window.__esdocCharactersCleanup = () => { + remember(); disposed = true; controller.abort(); clearTimeout(timer); clearTimeout(typingTimer); backgroundRequest++; + if (backgroundUrl) URL.revokeObjectURL(backgroundUrl); + for (const url of downloads) URL.revokeObjectURL(url); + for (const face of fontFaces.values()) document.fonts.delete(face); + window.__esdocCharactersCleanup = null; + }; + syncForm(); filterProperties(); app.hidden = false; +})(); diff --git a/static/js/navigation.js b/static/js/navigation.js index 5c306e7..eb007e0 100644 --- a/static/js/navigation.js +++ b/static/js/navigation.js @@ -111,6 +111,7 @@ window.__esdocWarperCleanup?.(); window.__esdocToolsCleanup?.(); window.__esdocColorsCleanup?.(); + window.__esdocCharactersCleanup?.(); window.__esdocAnimationsCleanup?.(); content.innerHTML = nextContent.innerHTML; document.title = nextDocument.title; diff --git a/templates/partials/head.html b/templates/partials/head.html index 054d571..93c237b 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -59,5 +59,9 @@ {% endif %} +{% if with_characters_js %} + + +{% endif %} {% if DEBUG %} {% endif %} diff --git a/templates/tools.html b/templates/tools.html index 05b8a71..cdba383 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -16,6 +16,7 @@

    Онлайн-инструменты

    diff --git a/templates/tools_characters.html b/templates/tools_characters.html new file mode 100644 index 0000000..7d78348 --- /dev/null +++ b/templates/tools_characters.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% set page_title = "Создатель персонажей — ES Doc" %} +{% set body_class = 'bg-solid scene-page' %} +{% set active = 'tools' %} +{% set with_characters_js = true %} +{% set extra_css = ['/static/css/characters.css'] %} +{% block content %} +
    +
    {{ lucide_icon('arrow-left') }} Инструменты

    Создатель персонажей

    +
    + + +
    +
    {% include "partials/footer.html" %}
    +{% endblock %} diff --git a/tests/characters_browser.cjs b/tests/characters_browser.cjs new file mode 100644 index 0000000..484ab28 --- /dev/null +++ b/tests/characters_browser.cjs @@ -0,0 +1,162 @@ +/* Run against a local ES Doc server. Set NODE_PATH to the bundled Node packages. */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const {chromium} = require('playwright'); +const target = process.env.CHARACTERS_TEST_URL || 'http://127.0.0.1:8013/tools/characters'; + +(async () => { + const browser = await chromium.launch({headless:true}); + const page = await browser.newPage({viewport:{width:1440,height:1100},colorScheme:'light'}); + const errors = [], writes = []; + page.on('pageerror',error => errors.push(error.message)); + page.on('request',request => { if (request.method() !== 'GET') writes.push(request.url()); }); + try { + await page.goto(target); await page.locator('#characters-app').waitFor({state:'visible'}); + const code = () => page.locator('#character-code').inputValue(); + assert.equal(await code(), 'define e = Character("Семён")'); + assert(await page.locator('#character-value-who_color').isVisible(),'Common appearance controls are immediately available'); + assert(await page.locator('#character-name-mode').isHidden(),'Name semantics are disclosed on request'); + assert(await page.locator('#character-mode-who_color').isHidden(),'Python mode does not compete with basic color input'); + assert.equal(await page.locator('#character-zoom').getAttribute('aria-pressed'),'true'); + await page.locator('#character-color-who_color').fill('#123456'); + assert.match(await code(), /who_color="#123456"/); + await page.locator('#character-value-who_color').fill(''); + assert.equal(await code(), 'define e = Character("Семён")','Clearing a basic field restores inheritance'); + await page.locator('#character-value-who_color').fill('#abc8'); + assert.match(await code(), /who_color="#abc8"/); + assert.equal(await page.locator('#character-preview-who').evaluate(el => el.style.color), 'rgba(170, 187, 204, 0.533)'); + await page.locator('#character-what-fields').locator('..').locator('summary').first().click(); + await page.locator('#character-value-what_bold').selectOption('False'); + assert.match(await code(), /what_bold=False/); + await page.locator('#character-identity-options > summary').click(); + await page.locator('#character-name-mode').selectOption('none'); + assert(await page.locator('#character-namebox').isHidden()); + assert.match(await code(), /Character\(\n None,/); + await page.locator('#character-name-mode').selectOption('text'); + await page.locator('#character-name').fill('Имя "с кавычками"'); + assert.match(await code(), /\\"с кавычками\\"/); + await page.locator('#character-variable').fill('bad name'); + assert(await page.locator('#character-copy').isDisabled()); + assert.equal(await page.locator('#character-variable').getAttribute('aria-invalid'),'true'); + await page.locator('#character-fix-error').click(); + assert.equal(await page.evaluate(() => document.activeElement.id),'character-variable'); + await page.locator('#character-variable').fill('e'); + await page.locator('#character-value-who_size').fill('42'); + const saved = await code(); + await page.locator('#character-reset').click(); + assert.equal(await code(), 'define e = Character("Семён")'); + await page.locator('#character-undo').click(); + assert.equal(await code(),saved); + await page.locator('#character-all-settings > summary').click(); + await page.locator('#character-property-search').fill('скорость'); + assert((await page.locator('#character-property option').allTextContents()).some(text => text.includes('what_slow_cps'))); + await page.locator('#character-property-search').fill('who_color'); + await page.locator('#character-switch-scope').click(); + assert.equal(await page.locator('#character-property-scope').inputValue(),'who'); + assert.equal(await page.locator('#character-property').inputValue(),'who_color'); + await page.locator('#character-property-scope').selectOption('what'); + await page.locator('#character-property-search').fill('outlines'); + await page.locator('#character-add-property').click(); + await page.locator('#character-remove-what_outlines').click(); + assert.equal(await page.locator('#character-value-what_outlines').count(),0,'An empty added field can be removed'); + await page.locator('#character-add-property').click(); + await page.locator('#character-value-what_outlines').fill('[(2, "#000", 0, 1)]'); + assert.match(await code(), /what_outlines=\[\(2, "#000", 0, 1\)\]/); + assert(await page.locator('#character-preview-what').evaluate(el => Boolean(el.style.textShadow))); + await page.locator('#character-value-what_outlines').fill('[(2, "#000", 0, 1)'); + assert(await page.locator('#character-copy').isDisabled()); + await page.locator('#character-value-what_outlines').fill('[(2, "#000", 0, 1)]'); + await page.locator('.character-custom-disclosure > summary').click(); + await page.locator('#character-custom-key').fill('show_myflag'); + await page.locator('#character-add-custom').click(); + await page.locator('#character-value-show_myflag').fill('True'); + assert.match(await code(), /show_myflag=True/); + assert(await page.locator('#character-preview-limitations').isVisible()); + await page.locator('#character-property-search').fill('padding'); + await page.locator('#character-property-scope').selectOption('window'); + await page.locator('#character-property').selectOption('window_padding'); + await page.locator('#character-add-property').click(); + await page.locator('#character-value-window_padding').fill('(20, 60)'); + const padding = await page.locator('#character-dialogue-window').evaluate(el => ({x:parseFloat(getComputedStyle(el).paddingLeft),y:parseFloat(getComputedStyle(el).paddingTop)})); + assert(Math.abs(padding.y / padding.x - 3) < .01,'RenPy two-tuple padding is horizontal then vertical'); + await page.locator('#character-value-window_padding').fill('(10, 20, 30, 40)'); + const edges = await page.locator('#character-dialogue-window').evaluate(el => ['paddingLeft','paddingTop','paddingRight','paddingBottom'].map(key => parseFloat(getComputedStyle(el)[key]))); + assert(edges.every((v,i) => Math.abs(v / edges[0] - (i+1)) < .01),'RenPy four-tuple order is left/top/right/bottom'); + await page.locator('#character-sample').fill('{b}Жирно{/b} {color=#f00}Красно{/color}'); + await page.locator('#character-replay').click(); + assert.equal(await page.locator('#character-preview-what span').count(),2,'Replay preserves inline formatting while revealing text'); + await page.locator('#character-stage').click(); + await page.locator('#character-sample').fill('Я говорю так!'); + const data = await page.evaluate(() => localStorage.getItem('es-doc-character-draft-v1')); + const file = {name:'e.character.json',mimeType:'application/json',buffer:Buffer.from(data)}; + await page.locator('#character-reset').click(); + await page.locator('#character-project-file').setInputFiles(file); + await page.waitForFunction(() => document.querySelector('#character-code').value.includes('show_myflag=True')); + await page.reload(); + await page.locator('#characters-app').waitFor({state:'visible'}); + assert.match(await code(), /show_myflag=True/); + const download = page.waitForEvent('download'); + await page.locator('#character-download').click(); + const artifact = await download; assert.equal(artifact.suggestedFilename(),'e.rpy'); + assert.match(fs.readFileSync(await artifact.path(),'utf8'),/show_myflag=True/); + await page.locator('#character-copy').click(); + await page.waitForFunction(() => /скопировано|выделен/.test(document.querySelector('#character-status').textContent)); + assert.match(await page.locator('#character-status').textContent(), /скопировано|выделен/); + await page.locator('.character-preview-options').first().locator('summary').click(); + await page.locator('#character-font-who').setInputFiles('static/fonts/inter-variable-cyrillic.woff2'); + await page.waitForFunction(() => document.querySelector('#character-preview-who').style.fontFamily.includes('character-preview-who')); + await page.locator('#character-clear-fonts').click(); + assert.equal(await page.locator('#character-preview-who').evaluate(el => el.style.fontFamily),''); + await page.locator('#character-background-file').setInputFiles('static/img/favicon.webp'); + await page.waitForFunction(() => document.querySelector('#character-stage').style.backgroundImage.includes('blob:')); + await page.locator('#character-clear-background').click(); + assert.equal(await page.locator('#character-stage').evaluate(el => el.style.backgroundImage),''); + await page.locator('#character-identity-options > summary').click(); + await page.locator('#character-name-mode').selectOption('expression'); + await page.locator('#character-name').fill('get_name'); + await page.locator('#character-preview-name').fill('Алиса'); + assert.equal(await page.locator('#character-preview-who').textContent(),'Алиса'); + await page.locator('#character-name-mode').selectOption('text'); + await page.locator('a.page-back').click(); + await page.waitForURL('**/tools'); + assert.equal(await page.evaluate(() => window.__esdocCharactersCleanup),null); + await page.locator('a[href="/tools/characters"]').click(); + await page.locator('#characters-app').waitFor({state:'visible'}); + assert.match(await code(), /show_myflag=True/); + await page.locator('#character-reset').click(); + await page.locator('.character-field').filter({has:page.locator('#character-value-who_color')}).locator('.character-field-options > summary').click(); + await page.locator('#character-enabled-who_color').check(); + assert.match(await code(), /who_color=""/,'Explicit empty strings remain representable'); + await page.locator('#character-remove-who_color').click(); + assert.equal(await code(),'define e = Character("Семён")'); + await page.evaluate(() => document.querySelectorAll('#characters-app details').forEach(el => el.open = false)); + fs.mkdirSync('.impeccable/review',{recursive:true}); + for (const [name,width,theme] of [['characters-desktop',1440,'light'],['characters-desktop-dark',1440,'dark'],['characters-medium',1280,'light'],['characters-mobile',390,'light'],['characters-mobile-dark',390,'dark']]) { + // Isolate theme/viewport capture from the integration page and its scroll state. + const capture = await browser.newPage({viewport:{width,height:width > 720 ? 1100 : 844},colorScheme:theme}); + await capture.emulateMedia({reducedMotion:'reduce'}); + await capture.goto(target); + await capture.locator('#characters-app').waitFor({state:'visible'}); + await capture.evaluate(() => document.fonts.ready); + assert(await capture.evaluate(() => document.documentElement.scrollWidth <= innerWidth),name + ' overflow'); + assert(await capture.locator('#character-preview-what').evaluate(el => parseFloat(getComputedStyle(el).fontSize) >= 16),name + ' readable initial preview'); + await capture.screenshot({path:'.impeccable/review/' + name + '.png',fullPage:true}); + if (width > 720) await capture.screenshot({path:'.impeccable/review/' + name + '-preview.png',clip:await capture.locator('#character-stage').boundingBox()}); + else await capture.locator('#character-stage').screenshot({path:'.impeccable/review/' + name + '-preview.png'}); + await capture.close(); + console.log('Captured ' + name); + } + await page.setViewportSize({width:390,height:844}); + await page.locator('#character-value-who_size').fill('36'); + await page.locator('[data-character-jump="code"]').click(); + assert.equal(await page.evaluate(() => document.activeElement.id),'character-code-heading'); + assert(await page.locator('[data-character-jump="settings"]').isVisible()); + await page.locator('[data-character-jump="settings"]').click(); + assert.equal(await page.evaluate(() => document.activeElement.id),'character-value-who_size','Return navigation restores the last edited field'); + await page.locator('[data-character-jump="code"]').click(); + await page.keyboard.press('Control+z'); + assert.equal(await code(),'define e = Character("Семён")','Undo shortcut works outside text editors'); + assert.deepEqual(errors,[]); assert.deepEqual(writes,[]); + console.log('Character workbench integration passed.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/characters_core.test.mjs b/tests/characters_core.test.mjs new file mode 100644 index 0000000..e9be01c --- /dev/null +++ b/tests/characters_core.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import {readFileSync, existsSync} from 'node:fs'; +import vm from 'node:vm'; +import test from 'node:test'; + +const window = {}; +for (const file of ['characters-catalog.js', 'characters-core.js']) { + const path = new URL('../static/js/' + file, import.meta.url); + if (existsSync(path)) vm.runInNewContext(readFileSync(path, 'utf8'), {window}); +} +const core = () => { assert.ok(window.ESDocCharacters); return window.ESDocCharacters; }; +const state = () => ({variable: 'e', name: 'Семён', nameMode: 'text', sample: 'Привет!', values: {}}); + +test('unset properties inherit; explicit False, zero and None survive export', () => { + const s = state(); + s.values = {what_bold: {mode: 'boolean', value: 'False'}, what_kerning: {mode: 'number', value: '0'}, callback: {mode: 'expression', value: 'None'}}; + const result = core().compile(s); + assert.deepEqual(Array.from(result.errors), []); + assert.match(result.code, /what_bold=False/); + assert.match(result.code, /what_kerning=0/); + assert.match(result.code, /callback=None/); + assert.doesNotMatch(result.code, /who_color/); +}); + +test('strings escape quotes, backslashes and newlines and keep RenPy tags', () => { + const s = state(); s.name = '"Имя"\\\n{b}[player]'; + assert.ok(core().compile(s).code.includes('"\\"Имя\\"\\\\\\n{b}[player]"')); +}); + +test('narration, inherited name and callable name are distinct', () => { + const s = state(); s.nameMode = 'none'; + assert.match(core().compile(s).code, /Character\(None\)/); + s.nameMode = 'inherit'; + assert.match(core().compile(s).code, /Character\(\)/); + s.nameMode = 'expression'; s.name = 'get_name'; + assert.match(core().compile(s).code, /Character\(get_name\)/); +}); + +test('Python values preserve tuples, calls and numeric int/float distinction without execution', () => { + const c = core(); + assert.equal(c.serialize({mode:'expression',value:'[(2, "#0008", 0, 1)]'}), '[(2, "#0008", 0, 1)]'); + assert.equal(c.serialize({mode:'expression',value:'my_callback'}), 'my_callback'); + assert.equal(c.serialize({mode:'number',value:'1.0'}), '1.0'); + assert.throws(() => c.serialize({mode:'number',value:'NaN'})); + assert.throws(() => c.serialize({mode:'expression',value:'Frame("x.png"'})); + assert.throws(() => c.serialize({mode:'expression',value:'True); hacked() #'})); +}); + +test('invalid identifiers and keyword injections block the entire export', () => { + const s = state(); s.variable = 'class'; + assert.ok(core().compile(s).errors.length); + assert.equal(core().compile(s).code, ''); + s.variable = 'семён'; + assert.equal(core().compile(s).errors.length, 0); + s.values['what_color); bad()'] = {mode:'text',value:'#fff'}; + assert.equal(core().compile(s).code, ''); + s.values = {name: {mode:'text',value:'Duplicate'}}; + assert.equal(core().compile(s).code, ''); +}); + +test('catalog covers offline properties and current text and position properties', () => { + const c = core(); + for (const key of ['who_outlines','what_slow_cps_multiplier','who_line_spacing','what_font','what_language','what_ruby_style','who_xanchor','what_xalign','window_background','namebox_padding','ctc_timedpause','predict_function','retain','advance','show_layer']) { + assert.ok(c.field(key), key); + } + assert.ok(window.ESDocCharacterCatalog.styles.length >= 129); +}); + +test('project import rejects corrupt or executable structure and preserves exact configured values', () => { + const s = state(); s.values.what_color = {mode:'text', value:'#abc8'}; + const c = core(); + const encoded = c.project(s); + assert.equal(c.compile(c.importProject(encoded)).code, c.compile(s).code); + for (const bad of ['{}', '{"version":99}', '{"version":1,"state":{"values":{"__proto__":{}}}}']) { + assert.throws(() => c.importProject(bad)); + } + assert.throws(() => c.importProject('null'), /Неподдерживаемый формат проекта/); +}); + +test('preview parser handles outline tuples and rejects Python calls without executing them', () => { + const c = core(); + assert.equal(c.literal('[(2, "#0008", 0, 1)]').ok, true); + assert.equal(c.literal('True').value, true); + assert.equal(c.literal('None').value, null); + assert.equal(c.literal('callback()').ok, false); + assert.equal(c.literal('1 + 2').ok, false); + assert.equal(c.literal('['.repeat(40) + ']'.repeat(40)).ok, false); +}); diff --git a/tests/test_tools.py b/tests/test_tools.py index 7197c08..09b5a9c 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -47,9 +47,25 @@ async def test_hub_links_to_tools_without_loading_unpacker(self): self.assertEqual(response.status_code, 200) self.assertIn('href="/tools/unpack"', response.text) self.assertIn('href="/tools/colors"', response.text) + self.assertIn('href="/tools/characters"', response.text) self.assertNotIn('tools-worker.js', response.text) self.assertNotIn('/static/js/tools.js', response.text) + async def test_character_workbench_is_discoverable_and_loads_only_its_runtime(self): + response = await self.get('/tools/characters') + self.assertEqual(response.status_code, 200) + for marker in ('Создатель персонажей — ES Doc', 'character-settings', 'character-preview-who', + 'character-preview-what', 'character-code', 'character-all-settings', + 'characters-catalog.js', 'characters-core.js', 'characters.js'): + self.assertIn(marker, response.text) + self.assertNotIn('tools-worker.js', response.text) + self.assertNotIn('colors.js', response.text) + for file in ('characters-catalog.js', 'characters-core.js', 'characters.js'): + asset = await self.get('/static/js/' + file) + self.assertEqual(asset.status_code, 200) + self.assertIn('/tools/characters', (await self.get('/sitemap.xml')).text) + self.assertIn('/tools/characters', [item['url'] for item in _section_items()]) + async def test_color_page_has_its_own_controls_and_scripts(self): response = await self.get('/tools/colors') self.assertEqual(response.status_code, 200) From 47a5cf456ca11f2a0598ee25b43ed399c965b095 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:45:01 +0300 Subject: [PATCH 08/27] chope: .gitignore updated --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3bc2aaa..04b487a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,6 @@ content/* !content/main .impeccable/critique/ +.impeccable/review/ .agents/ -.codex/ \ No newline at end of file +.codex/ From b435e8241d4a874f8fb69caaf3d7474b8fbcbde7 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:58:13 +0300 Subject: [PATCH 09/27] feature: universal converter added --- templates/tools.html | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/tools.html b/templates/tools.html index cdba383..12579a6 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -23,6 +23,7 @@

    Онлайн-инструменты

    ')) + + def test_short_fence_does_not_end_long_fenced_example(self): + html = render(':::info\n````text\n```\n:::\n````\n\nINSIDE\n:::\n')[2] + self.assertLess(html.index('INSIDE'), html.index('
    ', html.index('INSIDE'))) + self.assertIn('```\n:::\n', html) From d355592f373725e4f552debfabc4e349192cc1de Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:09:29 +0300 Subject: [PATCH 16/27] fix: respect standalone banner boundaries in nested containers --- app/utils/md/banner.py | 35 +++++++++++++++-------------------- app/utils/md/containers.py | 12 ++++++++---- tests/test_md_containers.py | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/app/utils/md/banner.py b/app/utils/md/banner.py index ccb9f9b..867f631 100644 --- a/app/utils/md/banner.py +++ b/app/utils/md/banner.py @@ -35,6 +35,20 @@ def render_banner_open(self, tokens, idx, options, env): def render_banner_close(self, tokens, idx, options, env): return '
    ' +def banner_end(state: StateBlock, startLine: int, endLine: int): + """Optional bounded note body and the line where parsing resumes.""" + nextLine = startLine + 1 + while nextLine < endLine: + pos = state.bMarks[nextLine] + state.tShift[nextLine] + line = state.src[pos:state.eMarks[nextLine]].strip() + if line == ':::': + return nextLine, nextLine + 1 + if not line or line.startswith(':::'): + break + nextLine += 1 + return startLine + 1, startLine + 1 + + def banner(): # `:::stub`, or `:::stub <короткая приписка>` on the same line. A multi-line # note may follow, closed by `:::` — but only while the lines run on without @@ -62,26 +76,7 @@ def block(state: StateBlock, startLine: int, endLine: int, silent: bool): # Find the optional note body. bodyEnd stays at startLine + 1 for the # bare single-line form; nextLine is where parsing resumes afterwards. - bodyEnd = nextLine = startLine + 1 - - while nextLine < endLine: - pos = state.bMarks[nextLine] + state.tShift[nextLine] - line = state.src[pos:state.eMarks[nextLine]].strip() - - if line == ':::': - bodyEnd = nextLine - nextLine += 1 - break - - # Blank line, or the start of some other block — the banner was - # written in its bare form and this belongs to the document. - if not line or line.startswith(':::'): - bodyEnd = nextLine = startLine + 1 - break - - nextLine += 1 - else: - bodyEnd = nextLine = startLine + 1 + bodyEnd, nextLine = banner_end(state, startLine, endLine) token = state.push('banner_open', 'aside', 1) token.meta = {'kind': name} diff --git a/app/utils/md/containers.py b/app/utils/md/containers.py index 009242c..34a47d0 100644 --- a/app/utils/md/containers.py +++ b/app/utils/md/containers.py @@ -22,8 +22,7 @@ Banners are a deliberate exception on the *inside* — they keep their own bounded scan (banner.py), because a banner is one or two sentences of page status and should never grow into a container. They are still counted here, -because from the outside a banner opens and closes with the same markers as -everything else. +with the same bounded rule here, so a standalone banner consumes no closer. """ import re @@ -103,8 +102,13 @@ def find_closer(state, startLine: int, endLine: int) -> int | None: if depth == 0: return line depth -= 1 - elif opener_name(text): - depth += 1 + elif name := opener_name(text): + if name in _NAMES: + depth += 1 + else: + from .banner import banner_end + _, next_line = banner_end(state, line, endLine) + line = next_line - 1 line += 1 diff --git a/tests/test_md_containers.py b/tests/test_md_containers.py index e703f70..2a9bd6d 100644 --- a/tests/test_md_containers.py +++ b/tests/test_md_containers.py @@ -1,11 +1,31 @@ import re import unittest from html import unescape +from unittest.mock import patch +from app.utils.config import CONFIG from app.utils.md import render class ContainerFenceTests(unittest.TestCase): + def test_standalone_banner_does_not_steal_the_outer_closer(self): + with patch.object(CONFIG, 'config', {}): + for marker in (':::stub', ':::stub A short note'): + with self.subTest(marker=marker): + html = render(f':::details Example\n{marker}\n\nINSIDE\n:::\n\nOUTSIDE\n')[2] + self.assertIn('
    ')) + self.assertGreater(html.index('OUTSIDE'), html.index('
    ')) + + def test_paired_banner_keeps_its_own_closer(self): + with patch.object(CONFIG, 'config', {}): + html = render(':::details Example\n:::stub\nA note\n:::\n\nINSIDE\n:::\n\nOUTSIDE\n')[2] + self.assertIn('
    ')) + self.assertLess(html.index(''), html.index('INSIDE')) + self.assertLess(html.index('INSIDE'), html.index('
    ')) + def test_fenced_marker_examples_do_not_close_or_open_containers(self): for fence in ('```', '~~~~'): for marker in (':::', ':::info', ':::details Literal'): From 51a5eb41b704ea68e82180e5f8af54a9c51c9818 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:15:08 +0300 Subject: [PATCH 17/27] fix: keep article and code rendering off the event loop --- app/routes/docs.py | 3 +- app/routes/resources.py | 3 +- tests/test_render_concurrency.py | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/test_render_concurrency.py diff --git a/app/routes/docs.py b/app/routes/docs.py index 0d2fb7b..f1ed9d1 100644 --- a/app/routes/docs.py +++ b/app/routes/docs.py @@ -1,3 +1,4 @@ +import asyncio import re from urllib.parse import quote @@ -89,7 +90,7 @@ async def page(doc, request: Request): if any(i in request.query_params for i in ['s', 'search']): return PlainTextResponse(re.sub(r'^(#+ )|(- )|(> )|(```\w*)|(:::\w*)|(---)$', '', text, flags=re.MULTILINE).replace('\n\n', '\n')) - title, nav, body = render(text) + title, nav, body = await asyncio.to_thread(render, text) if any(i in request.query_params for i in ['r', 'raw']): return PlainTextResponse(body) diff --git a/app/routes/resources.py b/app/routes/resources.py index 1a475c3..b37ad5a 100644 --- a/app/routes/resources.py +++ b/app/routes/resources.py @@ -1,3 +1,4 @@ +import asyncio import html import os from pathlib import Path @@ -218,7 +219,7 @@ async def browser(request: Request, path=''): if target.is_file(): size = target.stat().st_size kind = FILE_KINDS.get(target.suffix.lower(), 'other') - view = _file_view(target) + view = await asyncio.to_thread(_file_view, target) sub = [KIND_LABELS[kind], _human_size(size)] if view['mode'] == 'code': sub.append(view['lines']) diff --git a/tests/test_render_concurrency.py b/tests/test_render_concurrency.py new file mode 100644 index 0000000..eb1cabf --- /dev/null +++ b/tests/test_render_concurrency.py @@ -0,0 +1,49 @@ +import asyncio +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory +from threading import Event +import unittest +from unittest.mock import patch + +from httpx import ASGITransport, AsyncClient + +from app.app import app +from app.utils.config import CONFIG + + +class RenderConcurrencyTests(unittest.IsolatedAsyncioTestCase): + async def assert_responsive(self, module_name, function_name, url, setting, filename, source): + module = importlib.import_module(module_name) + render = getattr(module, function_name) + started, release = Event(), Event() + + def gated_render(*args): + started.set() + release.wait(2) + return render(*args) + + with TemporaryDirectory() as directory: + Path(directory, filename).write_text(source, encoding='utf-8') + with (patch.object(CONFIG, setting, Path(directory)), + patch.object(module, function_name, gated_render)): + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://localhost') as client: + page = asyncio.create_task(client.get(url)) + try: + self.assertTrue(await asyncio.to_thread(started.wait, 3)) + health = await client.get('/healthz') + self.assertEqual(health.status_code, 200) + self.assertFalse(page.done(), 'Rendering blocked the event loop until it finished') + finally: + release.set() + response = await page + self.assertEqual(response.status_code, 200) + self.assertIn('hello', response.text) + + async def test_article_rendering_allows_other_requests(self): + await self.assert_responsive('app.routes.docs', 'render', '/docs/demo', + 'docs_path', 'demo.md', '# Demo\n\nhello') + + async def test_code_preview_allows_other_requests(self): + await self.assert_responsive('app.routes.resources', '_file_view', '/resources/browser/demo.rpy', + 'res_path', 'demo.rpy', 'label hello:\n pass\n') From 3ebfe090a1bedc2f274f1b216d4c15e72e00172c Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:16:12 +0300 Subject: [PATCH 18/27] fix: bound archive preview reads before allocating assets --- static/tools/engine.py | 10 +++++++++- tests/test_tools_engine.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/static/tools/engine.py b/static/tools/engine.py index f5a4fa3..39bf751 100644 --- a/static/tools/engine.py +++ b/static/tools/engine.py @@ -18,6 +18,7 @@ from unrpa import UnRPA CHUNK = 256 * 1024 +MAX_PREVIEW_BYTES = 64 * 1024 * 1024 def safe_path(value): @@ -304,15 +305,22 @@ def read(self, key, options): warnings.append(f'Исходный файл {target} уже есть в архиве, сохранён без замены.') path = target compiled = False + size = entry['size'] if segments is None else sum(length for _, length, _ in segments) + if size > MAX_PREVIEW_BYTES: + raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.') with open(self.files[entry['source']]['source'], 'rb') as source: if segments is None: - data = source.read() + data = source.read(MAX_PREVIEW_BYTES + 1) else: data = b''.join(iter(Segments(source, segments).read, b'')) + if len(data) > MAX_PREVIEW_BYTES: + raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.') if compiled: data, logs = pyc_decompiler.decompile(data) if path.lower().endswith('.pyc') else decompile(data, options) warnings.extend(str(log) for log in logs) path = target + if len(data) > MAX_PREVIEW_BYTES: + raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.') result = (path, data, warnings) # Cache the current preview only, not every expanded archive asset. self.cached = (cache_key, result) diff --git a/tests/test_tools_engine.py b/tests/test_tools_engine.py index c72b2cd..3cfc792 100644 --- a/tests/test_tools_engine.py +++ b/tests/test_tools_engine.py @@ -133,6 +133,42 @@ def test_lazy_preview_preserves_existing_source_and_split_segments(self): self.assertEqual((path, data), ('script.rpy', b'label start:\n pass\n')) self.assertEqual(len(warnings), 1) + def test_preview_rejects_oversized_entries_before_reading_them(self): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + source = Path(directory) / 'archive' + source.write_bytes(archive({'big.bin': [(b'0123456789', b'prefix')], + 'small.txt': [(b'ok', b'')]})) + catalog = self.engine.Catalog([{'path': 'data.rpa', 'source': str(source)}]) + self.assertEqual(len(catalog.listing()), 2) + with patch.object(self.engine, 'MAX_PREVIEW_BYTES', 8, create=True): + with patch('builtins.open', side_effect=AssertionError('Oversized entry was opened')): + with self.assertRaisesRegex(ValueError, 'Скачайте архив'): + catalog.read('0:big.bin', {}) + self.assertIsNone(catalog.cached) + self.assertEqual(catalog.read('0:small.txt', {})[1], b'ok') + + def test_preview_limit_uses_existing_source_size_instead_of_compiled_size(self): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + source = Path(directory) / 'archive' + source.write_bytes(archive({'large.rpyc': [(b'x', b'')], + 'large.rpy': [(b'0123456789', b'')], + 'small.rpyc': [(b'0123456789', b'')], + 'small.rpy': [(b'ok', b'')]})) + catalog = self.engine.Catalog([{'path': 'data.rpa', 'source': str(source)}]) + with patch.object(self.engine, 'MAX_PREVIEW_BYTES', 8, create=True): + with self.assertRaisesRegex(ValueError, 'Скачайте архив'): + catalog.read('0:large.rpyc', {}) + self.assertEqual(catalog.read('0:small.rpyc', {})[:2], ('small.rpy', b'ok')) + + def test_preview_limit_also_applies_to_loose_compiled_files(self): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + source = Path(directory) / 'script' + source.write_bytes(b'0123456789') + catalog = self.engine.Catalog([{'path': 'script.rpyc', 'source': str(source)}]) + with patch.object(self.engine, 'MAX_PREVIEW_BYTES', 8, create=True): + with self.assertRaisesRegex(ValueError, 'Скачайте архив'): + catalog.read('0:script.rpyc', {}) + def test_catalog_reports_bad_archives_without_hiding_good_sources(self): with TemporaryDirectory(dir=ROOT / 'temp') as directory: source = Path(directory) / 'good' From d24f02782f8b07a0e2af8e88440e10ce59210b37 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:16:48 +0300 Subject: [PATCH 19/27] fix: discard pending search results when queries change or close --- static/js/search.js | 9 ++++- tests/search_results.test.mjs | 63 ++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/static/js/search.js b/static/js/search.js index f1b939b..34ef52f 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -37,6 +37,8 @@ function open() { list.hidden = false; input.setAttribute('aria-expanded', 'true'); } function close() { + ++seq; + clearTimeout(timer); list.hidden = true; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); @@ -153,7 +155,11 @@ // ── Events ─────────────────────────────────────────────── var timer; input.addEventListener('input', function () { - clearTimeout(timer); + close(); + matches = []; + list.innerHTML = ''; + announce(''); + if (!input.value.trim()) return; timer = setTimeout(update, 120); }); @@ -192,6 +198,7 @@ input.focus(); } function closeSheet(returnFocus) { + close(); var wasOpen = document.body.classList.contains('search-open'); document.body.classList.remove('search-open'); if (toggle) toggle.setAttribute('aria-expanded', 'false'); diff --git a/tests/search_results.test.mjs b/tests/search_results.test.mjs index 0e50588..f825dbc 100644 --- a/tests/search_results.test.mjs +++ b/tests/search_results.test.mjs @@ -5,7 +5,7 @@ import vm from 'node:vm'; const script = readFileSync(new URL('../static/js/search.js', import.meta.url), 'utf8'); -async function searchBox(rows) { +async function searchBox(rows, fetchRows = async () => rows) { const handlers = {}; const attrs = {}; const input = { @@ -28,18 +28,71 @@ async function searchBox(rows) { vm.runInNewContext(script, { document: { getElementById: id => ({ 'site-search': form, 'site-search-input': input, 'site-search-results': list })[id], - querySelector: () => null, addEventListener: () => {}, - body: { classList: { add: () => {} } }, + querySelector: () => null, + addEventListener: (event, handler) => { handlers['document-' + event] = handler; }, + body: { classList: { add: () => {}, remove: () => {}, contains: () => false } }, }, - window, fetch: async () => ({ ok: true, json: async () => rows }), + window, fetch: async () => ({ ok: true, json: fetchRows }), setTimeout: fn => { update = fn; }, clearTimeout: () => {}, }); handlers.input(); update(); await new Promise(resolve => setImmediate(resolve)); - return { handlers, input, list, status, window, attrs }; + return { handlers, input, list, status, window, attrs, flush: () => update() }; } +for (const dismiss of ['clear', 'Escape', 'outside click']) { + for (const fails of [false, true]) { + test(`pending search ${fails ? 'failure' : 'results'} stays dismissed after ${dismiss}`, async () => { + let resolve, reject; + const pending = new Promise((yes, no) => { resolve = yes; reject = no; }); + const box = await searchBox([], () => pending); + if (dismiss === 'clear') { + box.input.value = ''; + box.handlers.input(); + box.flush(); + } else if (dismiss === 'Escape') { + box.handlers.keydown({ key: 'Escape', preventDefault: () => {} }); + } else { + box.handlers['document-click']({ target: {} }); + } + if (fails) reject(new Error('offline')); + else resolve([{ label: 'Луна', url: '/stale' }]); + await new Promise(done => setImmediate(done)); + assert.equal(box.list.hidden, true); + assert.equal(box.attrs['aria-expanded'], 'false'); + assert.equal(box.status.textContent || '', ''); + }); + } +} + +test('typing another query invalidates results before its debounce fires', async () => { + let resolve; + const pending = new Promise(done => { resolve = done; }); + const box = await searchBox([], () => pending); + box.input.value = 'Ночь'; + box.handlers.input(); + resolve([{ label: 'Луна', url: '/stale' }]); + await new Promise(done => setImmediate(done)); + assert.equal(box.list.hidden, true); + assert.ok(!box.list.innerHTML.includes('Луна')); +}); + +test('an older response cannot replace results from the latest query', async () => { + let resolveOld; + const old = new Promise(done => { resolveOld = done; }); + let calls = 0; + const box = await searchBox([], () => ++calls === 1 ? old : [{ label: 'Ночь', url: '/latest' }]); + box.input.value = 'Ночь'; + box.handlers.input(); + box.flush(); + await new Promise(done => setImmediate(done)); + resolveOld([{ label: 'Луна', url: '/stale' }]); + await new Promise(done => setImmediate(done)); + box.handlers.keydown({ key: 'Enter', preventDefault: () => {} }); + assert.equal(box.window.location.href, '/latest'); +}); + test('mixed global results have distinct groups and retain ranking within each group', async () => { const box = await searchBox([ { kind: 'file', label: 'Луна.rpy', url: '/resources/browser/Луна.rpy' }, From 3f79203ff1997fea02501cffa07aee77f60bacf9 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:19:14 +0300 Subject: [PATCH 20/27] fix: honor the latest navigation during overlapping page loads --- static/js/navigation.js | 98 ++++++++++++-------- tests/navigation.test.mjs | 190 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 tests/navigation.test.mjs diff --git a/static/js/navigation.js b/static/js/navigation.js index eb007e0..5163264 100644 --- a/static/js/navigation.js +++ b/static/js/navigation.js @@ -12,7 +12,8 @@ if (!content || !window.fetch || !window.DOMParser) return; let navigationId = 0; - let navigating = false; + let navigationController; + let pageQueue = Promise.resolve(); const sectionFor = pathname => { if (pathname === '/') return ''; @@ -37,7 +38,8 @@ }); }; - const updateStyles = async nextDocument => { + const updateStyles = async (nextDocument, signal) => { + if (signal.aborted) return; const hrefs = [...nextDocument.head.querySelectorAll('link[data-page-style]')] .map(link => link.href) .filter(Boolean); @@ -51,21 +53,29 @@ }); await Promise.all(newLinks.map(link => new Promise(resolve => { - link.addEventListener('load', resolve, { once: true }); - link.addEventListener('error', resolve, { once: true }); + const finish = () => { + signal.removeEventListener('abort', cancel); + resolve(); + }; + const cancel = () => { link.remove(); finish(); }; + signal.addEventListener('abort', cancel, { once: true }); + link.addEventListener('load', finish, { once: true }); + link.addEventListener('error', finish, { once: true }); document.head.appendChild(link); }))); - oldLinks.forEach(link => link.remove()); + if (signal.aborted) newLinks.forEach(link => link.remove()); + else oldLinks.forEach(link => link.remove()); }; - const loadPageScripts = async nextDocument => { + const loadPageScripts = async (nextDocument, id) => { document.querySelectorAll('script[data-soft-page-script]').forEach(script => script.remove()); const sources = [...nextDocument.head.querySelectorAll('script[data-page-script][src]')] .map(script => script.src); for (const src of sources) { + if (id !== navigationId) return; await new Promise(resolve => { const script = document.createElement('script'); script.src = src; @@ -78,34 +88,9 @@ } }; - const loadPage = async (href, pushHistory) => { - const id = ++navigationId; - const url = new URL(href, location.href); - - let response; - try { - response = await fetch(url.href, { - credentials: 'same-origin', - headers: { Accept: 'text/html' }, - }); - } catch (error) { - location.href = url.href; - return; - } - - if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) { - location.href = url.href; - return; - } - - const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html'); - const nextContent = nextDocument.getElementById('site-content'); - if (!nextContent || id !== navigationId) { - if (id === navigationId) location.href = url.href; - return; - } - - await updateStyles(nextDocument); + const commitPage = async (nextDocument, nextContent, url, pushHistory, id, signal) => { + if (id !== navigationId) return; + await updateStyles(nextDocument, signal); if (id !== navigationId) return; window.__esdocWarperCleanup?.(); @@ -131,7 +116,7 @@ if (pushHistory) history.pushState({}, '', url.href); if (!url.hash) window.scrollTo(0, 0); - await loadPageScripts(nextDocument); + await loadPageScripts(nextDocument, id); if (id !== navigationId) return; // Soft navigation replaces the document without the browser's native @@ -148,6 +133,40 @@ window.dispatchEvent(new CustomEvent('esdoc:navigation', { detail: { url: url.href } })); }; + const loadPage = async (href, pushHistory) => { + const id = ++navigationId; + navigationController?.abort(); + navigationController = new AbortController(); + const signal = navigationController.signal; + const url = new URL(href, location.href); + + try { + const response = await fetch(url.href, { + credentials: 'same-origin', + headers: { Accept: 'text/html' }, + signal, + }); + if (id !== navigationId) return; + if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) { + location.href = url.href; + return; + } + const text = await response.text(); + if (id !== navigationId) return; + const nextDocument = new DOMParser().parseFromString(text, 'text/html'); + const nextContent = nextDocument.getElementById('site-content'); + if (!nextContent) { location.href = url.href; return; } + + // A pending classic script must finish against its original DOM. + // Fetches can overlap, but page replacement and scripts run in order. + const job = pageQueue.then(() => commitPage(nextDocument, nextContent, url, pushHistory, id, signal)); + pageQueue = job.catch(() => {}); + await job; + } catch (error) { + if (id === navigationId) location.href = url.href; + } + }; + const shouldHandle = (event, link) => { if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.target || link.hasAttribute('download')) return false; @@ -160,16 +179,13 @@ document.addEventListener('click', event => { const link = event.target.closest?.('a'); - if (!link || !shouldHandle(event, link) || navigating) return; + if (!link || !shouldHandle(event, link)) return; event.preventDefault(); - navigating = true; - loadPage(link.href, true).finally(() => { navigating = false; }); + loadPage(link.href, true); }); window.addEventListener('popstate', () => { - if (navigating) return; - navigating = true; - loadPage(location.href, false).finally(() => { navigating = false; }); + loadPage(location.href, false); }); })(); diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs new file mode 100644 index 0000000..98236e4 --- /dev/null +++ b/tests/navigation.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../static/js/navigation.js', import.meta.url), 'utf8'); +const tick = () => new Promise(resolve => setImmediate(resolve)); + +function navigation({ holdStyles = false, holdScripts = false } = {}) { + const events = {}, requests = [], links = [], scripts = [], pushes = [], redirects = []; + const content = { innerHTML: 'initial' }; + let current = new URL('https://example.test/docs/current'); + const location = { + get href() { return current.href; }, + set href(value) { redirects.push(value); current = new URL(value, current); }, + get origin() { return current.origin; }, + get pathname() { return current.pathname; }, + get search() { return current.search; }, + }; + function element(kind) { + const handlers = {}; + return { + kind, dataset: {}, removed: false, + addEventListener: (name, fn) => { handlers[name] = fn; }, + remove() { this.removed = true; }, + loaded() { handlers.load?.(); }, + }; + } + const document = { + title: 'initial', body: { className: '' }, documentElement: { dataset: {} }, + getElementById: () => content, + addEventListener: (name, fn) => { events['document-' + name] = fn; }, + querySelectorAll: selector => selector.startsWith('script') ? scripts.filter(s => !s.removed) : [], + createElement: element, + head: { + querySelectorAll: () => links.filter(link => !link.removed), + appendChild(item) { + (item.kind === 'link' ? links : scripts).push(item); + if (item.kind === 'link' ? !holdStyles : !holdScripts) queueMicrotask(() => item.loaded()); + }, + }, + }; + const fetch = (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })); + const window = { + fetch, DOMParser: true, scrollTo() {}, dispatchEvent() {}, + addEventListener: (name, fn) => { events[name] = fn; }, + }; + const history = { pushState(state, title, url) { pushes.push(url); current = new URL(url); } }; + class DOMParser { + parseFromString(text) { + const page = JSON.parse(text); + return { + title: page.name, body: { className: page.name }, + getElementById: () => ({ innerHTML: page.name }), + head: { querySelectorAll: selector => selector.startsWith('link') + ? (page.styles || []).map(href => ({ href })) + : (page.scripts || []).map(src => ({ src })) }, + }; + } + } + vm.runInNewContext(source, { + window, document, fetch, location, history, DOMParser, URL, AbortController, + CustomEvent: class {}, localStorage: { getItem: () => null }, + }); + return { + content, document, location, requests, links, scripts, pushes, redirects, + pop(path) { current = new URL(path, current); events.popstate(); }, + click(path) { + let prevented = false; + events['document-click']({ + button: 0, target: { closest: () => ({ href: new URL(path, current).href, hasAttribute: () => false }) }, + preventDefault() { prevented = true; }, + }); + return prevented; + }, + respond(index, page, { ok = true, type = 'text/html' } = {}) { + requests[index].resolve({ ok, headers: { get: () => type }, text: async () => JSON.stringify(page) }); + }, + }; +} + +test('the latest Back or Forward event wins even while the previous fetch is pending', async () => { + const nav = navigation(); + nav.pop('/docs/first'); + nav.pop('/docs/latest'); + assert.equal(nav.requests.length, 2); + assert.equal(nav.requests[0].options.signal.aborted, true); + nav.respond(1, { name: 'latest' }); + await tick(); + nav.respond(0, { name: 'first' }); + await tick(); + assert.equal(nav.content.innerHTML, 'latest'); + assert.equal(nav.location.pathname, '/docs/latest'); + assert.deepEqual(nav.pushes, []); +}); + +test('Back supersedes a pending clicked link without creating another history entry', async () => { + const nav = navigation(); + assert.equal(nav.click('/docs/clicked'), true); + nav.pop('/docs/back'); + assert.equal(nav.requests.length, 2); + nav.respond(1, { name: 'back' }); + nav.respond(0, { name: 'clicked' }); + await tick(); + assert.equal(nav.content.innerHTML, 'back'); + assert.deepEqual(nav.pushes, []); +}); + +for (const failure of ['network', 'HTTP', 'body']) { + test(`a stale ${failure} failure cannot redirect away from the latest navigation`, async () => { + const nav = navigation(); + nav.pop('/docs/first'); + nav.pop('/docs/latest'); + assert.equal(nav.requests.length, 2); + nav.respond(1, { name: 'latest' }); + await tick(); + if (failure === 'network') nav.requests[0].reject(new Error('offline')); + else if (failure === 'body') nav.requests[0].resolve({ + ok: true, headers: { get: () => 'text/html' }, text: async () => { throw new Error('body failed'); }, + }); + else nav.respond(0, {}, { ok: false }); + await tick(); + assert.deepEqual(nav.redirects, []); + assert.equal(nav.content.innerHTML, 'latest'); + }); +} + +test('superseded stylesheet loads are removed without removing the current page styles', async () => { + const nav = navigation({ holdStyles: true }); + nav.pop('/docs/first'); + nav.respond(0, { name: 'first', styles: ['/first.css'] }); + await tick(); + nav.pop('/docs/latest'); + assert.equal(nav.requests.length, 2); + nav.respond(1, { name: 'latest', styles: ['/latest.css'] }); + await tick(); + const latest = nav.links.find(link => link.href === '/latest.css'); + latest.loaded(); + await tick(); + nav.links.find(link => link.href === '/first.css').loaded(); + await tick(); + assert.equal(nav.content.innerHTML, 'latest'); + assert.deepEqual(nav.links.filter(link => !link.removed).map(link => link.href), ['/latest.css']); +}); + +test('pending page scripts finish before the next page replaces their DOM', async () => { + const nav = navigation({ holdScripts: true }); + nav.pop('/docs/first'); + nav.respond(0, { name: 'first', scripts: ['/first.js', '/obsolete.js'] }); + await tick(); + nav.pop('/docs/latest'); + assert.equal(nav.requests.length, 2); + nav.respond(1, { name: 'latest' }); + await tick(); + assert.equal(nav.content.innerHTML, 'first'); + nav.scripts[0].loaded(); + await tick(); + assert.equal(nav.content.innerHTML, 'latest'); + assert.ok(!nav.scripts.some(script => script.src === '/obsolete.js')); +}); + +test('ordinary clicked navigation still pushes history and updates the page', async () => { + const nav = navigation(); + assert.equal(nav.click('/docs/new'), true); + nav.respond(0, { name: 'new', scripts: ['/new.js'], styles: ['/new.css'] }); + await tick(); + assert.equal(nav.content.innerHTML, 'new'); + assert.deepEqual(nav.pushes, ['https://example.test/docs/new']); + assert.equal(nav.scripts.length, 1); +}); + +test('a second clicked link supersedes the first without a stale history push', async () => { + const nav = navigation(); + assert.equal(nav.click('/docs/first'), true); + assert.equal(nav.click('/docs/latest'), true); + nav.respond(1, { name: 'latest' }); + await tick(); + nav.respond(0, { name: 'first' }); + await tick(); + assert.equal(nav.content.innerHTML, 'latest'); + assert.deepEqual(nav.pushes, ['https://example.test/docs/latest']); +}); + +test('an active navigation failure still falls back to the browser', async () => { + const nav = navigation(); + nav.pop('/docs/unavailable'); + nav.requests[0].reject(new Error('offline')); + await tick(); + assert.deepEqual(nav.redirects, ['https://example.test/docs/unavailable']); +}); From cbefcca9d31a21c4bf28836176c93e9ed1bcb2a9 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:21:32 +0300 Subject: [PATCH 21/27] fix: keep audio playback available when browser storage is denied --- static/js/player.js | 18 ++++-- tests/player_storage.test.mjs | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 tests/player_storage.test.mjs diff --git a/static/js/player.js b/static/js/player.js index b48988b..c8721e4 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -19,6 +19,11 @@ if (!clearPlayerState || !readPlayerState || !shouldClearOnPageHide || !writePlayerState) return; + let volumeStorage = null; + let playerStorage = null; + try { volumeStorage = localStorage; } catch (error) {} + try { playerStorage = sessionStorage; } catch (error) {} + let buttons = []; let status = document.getElementById('res-copy-status'); @@ -32,7 +37,8 @@ const audio = new Audio(); audio.preload = 'none'; - const storedVolume = parseFloat(localStorage.getItem('es-doc-volume') ?? '1'); + let storedVolume = 1; + try { storedVolume = parseFloat(volumeStorage?.getItem('es-doc-volume') ?? '1'); } catch (error) {} audio.volume = Number.isFinite(storedVolume) ? Math.min(Math.max(storedVolume, 0), 1) : 1; let current = null; @@ -99,7 +105,7 @@ const persist = (playing = !audio.paused, time = audio.currentTime) => { if (!currentSrc) return; - writePlayerState(sessionStorage, { + writePlayerState(playerStorage, { src: currentSrc, name: currentName, time: Number.isFinite(time) && time >= 0 ? time : 0, @@ -151,7 +157,7 @@ const setVolume = value => { const volume = Math.min(Math.max(value, 0), 1); audio.volume = volume; - localStorage.setItem('es-doc-volume', String(volume)); + try { volumeStorage?.setItem('es-doc-volume', String(volume)); } catch (error) {} barVolume.value = volume; setFill(barVolume); }; @@ -167,7 +173,7 @@ currentSrc = ''; currentName = ''; finished = false; - clearPlayerState(sessionStorage); + clearPlayerState(playerStorage); }; const finish = () => { @@ -256,7 +262,7 @@ bindButtons(); - const saved = readPlayerState(sessionStorage); + const saved = readPlayerState(playerStorage); if (saved) { current = buttons.find(button => button.dataset.playSrc === saved.src) || null; currentSrc = saved.src; @@ -284,7 +290,7 @@ // object alive without a gap (especially important in Firefox). if (shouldClearOnPageHide(event)) { audio.pause(); - clearPlayerState(sessionStorage); + clearPlayerState(playerStorage); } else { persist(!audio.paused); } diff --git a/tests/player_storage.test.mjs b/tests/player_storage.test.mjs new file mode 100644 index 0000000..2eea3b2 --- /dev/null +++ b/tests/player_storage.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const load = name => readFileSync(new URL('../static/js/' + name, import.meta.url), 'utf8'); + +class Element { + constructor() { + this.attrs = {}; this.dataset = {}; this.handlers = {}; this.children = new Map(); + this.style = { setProperty() {} }; + const classes = new Set(); + this.classList = { + add: (...names) => names.forEach(name => classes.add(name)), + remove: (...names) => names.forEach(name => classes.delete(name)), + contains: name => classes.has(name), + }; + } + addEventListener(name, fn) { (this.handlers[name] ||= []).push(fn); } + emit(name, event = {}) { (this.handlers[name] || []).forEach(fn => fn(event)); } + setAttribute(name, value) { this.attrs[name] = value; } + querySelector(selector) { + if (!this.children.has(selector)) this.children.set(selector, new Element()); + return this.children.get(selector); + } + contains() { return false; } + matches() { return false; } +} + +function storage(values = {}, fails) { + const items = new Map(Object.entries(values)); + const check = operation => { if (fails === operation) throw new Error('Storage denied'); }; + return { + getItem(key) { check('getItem'); return items.get(key) ?? null; }, + setItem(key, value) { check('setItem'); items.set(key, value); }, + removeItem(key) { check('removeItem'); items.delete(key); }, + items, + }; +} + +function player(local, session, deniedGetter) { + const button = new Element(); + button.hidden = true; + button.dataset = { playSrc: '/resource/audio/test.ogg', playName: 'Test track' }; + let audio; + class Audio extends Element { + constructor() { super(); audio = this; this.paused = true; this.currentTime = 0; this.duration = 120; } + play() { this.paused = false; this.emit('play'); return Promise.resolve(); } + pause() { this.paused = true; this.emit('pause'); } + } + const bars = [], status = {}, window = new Element(); + const document = { + getElementById: () => status, + createElement: () => new Element(), + querySelectorAll: selector => selector === '[data-play-src]' ? [button] : [], + body: { appendChild: item => bars.push(item) }, + }; + const context = vm.createContext({ window, document, Audio, localStorage: local, sessionStorage: session }); + if (deniedGetter) Object.defineProperty(context, deniedGetter, { + get() { throw new Error('SecurityError: storage access denied'); }, + }); + vm.runInContext(load('icons.js'), context); + vm.runInContext(load('player-state.js'), context); + vm.runInContext(load('player.js'), context); + return { audio, button, bar: bars[0], window, status }; +} + +for (const target of ['localStorage', 'sessionStorage']) { + for (const failure of ['getter', 'getItem', 'setItem', 'removeItem']) { + test(`audio controls work when ${target} ${failure} is denied`, () => { + const local = storage({}, target === 'localStorage' ? failure : undefined); + const session = storage({}, target === 'sessionStorage' ? failure : undefined); + const ui = player(local, session, failure === 'getter' ? target : undefined); + assert.equal(ui.button.hidden, false); + ui.button.emit('click'); + assert.equal(ui.audio.paused, false); + assert.equal(ui.button.attrs['aria-pressed'], 'true'); + assert.equal(ui.bar.classList.contains('res-nowplaying--visible'), true); + const volume = ui.bar.querySelector('.res-volume input'); + volume.value = '0.35'; + volume.emit('input'); + assert.equal(ui.audio.volume, 0.35); + assert.equal(volume.value, 0.35); + ui.bar.querySelector('.res-nowplaying-pause').emit('click'); + assert.equal(ui.audio.paused, true); + ui.window.emit('pagehide', { persisted: false }); + }); + } +} + +test('available storage still restores volume and track position and persists playback', () => { + const local = storage({ 'es-doc-volume': '0.25' }); + const session = storage({ 'es-doc-player-v2': JSON.stringify({ + src: '/resource/audio/test.ogg', name: 'Saved track', time: 15, playing: true, repeat: true, + }) }); + const ui = player(local, session); + assert.equal(ui.audio.volume, 0.25); + assert.equal(ui.audio.paused, false); + assert.equal(ui.audio.loop, true); + ui.audio.emit('loadedmetadata'); + assert.equal(ui.audio.currentTime, 15); + ui.audio.emit('timeupdate'); + assert.equal(JSON.parse(session.items.get('es-doc-player-v2')).time, 15); + ui.window.emit('pagehide', { persisted: false }); + assert.equal(session.items.has('es-doc-player-v2'), false); +}); From 6e19fed7cafed115603f01ba78b30be802b9de06 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:22:33 +0300 Subject: [PATCH 22/27] fix: dispose article listeners and observers during navigation --- static/js/docs.js | 45 +++++++++---- static/js/navigation.js | 1 + tests/docs_lifecycle.test.mjs | 117 ++++++++++++++++++++++++++++++++++ tests/navigation.test.mjs | 13 +++- 4 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 tests/docs_lifecycle.test.mjs diff --git a/static/js/docs.js b/static/js/docs.js index 4d5fa8e..c996b09 100644 --- a/static/js/docs.js +++ b/static/js/docs.js @@ -1,5 +1,22 @@ /* Documentation navigation; native disclosures also work without scripts. */ (function () { + window.__esdocDocsCleanup?.(); + const controller = new AbortController(); + const signal = controller.signal; + let disposed = false; + let frame = 0; + let navigationFrame = 0; + let observer; + window.__esdocDocsCleanup = () => { + if (disposed) return; + disposed = true; + controller.abort(); + observer?.disconnect(); + cancelAnimationFrame(frame); + cancelAnimationFrame(navigationFrame); + window.__esdocDocsCleanup = null; + }; + const tree = document.getElementById('sidebar-all'); const contents = document.getElementById('sidebar-contents'); const desktopTree = matchMedia('(min-width: 62em)'); @@ -11,20 +28,22 @@ const hadFocus = details.contains(document.activeElement); details.open = media.matches; if (hadFocus && !details.open) details.querySelector('summary').focus({preventScroll: true}); - }); + }, {signal}); details.addEventListener('keydown', event => { if (event.key !== 'Escape' || !details.open) return; details.open = false; details.querySelector('summary').focus({preventScroll: true}); - }); + }, {signal}); } adaptDisclosure(tree, desktopTree); adaptDisclosure(contents, desktopContents); if (contents && window.ResizeObserver) { const summary = contents.querySelector('summary'); - new ResizeObserver(() => { + observer = new ResizeObserver(() => { + if (disposed) return; document.documentElement.style.setProperty('--doc-contents-h', summary.offsetHeight + 'px'); - }).observe(summary); + }); + observer.observe(summary); } // Scroll only the independent rail; preserve the document's deep link. @@ -45,8 +64,8 @@ const headings = [...document.querySelectorAll('.content .heading')].filter(heading => links.has(heading.id)); if (!headings.length) return; let active = null; - let frame = 0; function update() { + if (disposed) return; frame = 0; const offset = (parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 64) + 24; let heading; @@ -81,12 +100,13 @@ active.setAttribute('aria-current', 'location'); } function scheduleUpdate() { + if (disposed) return; if (!frame) frame = requestAnimationFrame(update); } - addEventListener('scroll', scheduleUpdate, {passive: true}); - addEventListener('resize', scheduleUpdate); - addEventListener('hashchange', scheduleUpdate); - addEventListener('load', scheduleUpdate); + addEventListener('scroll', scheduleUpdate, {passive: true, signal}); + addEventListener('resize', scheduleUpdate, {signal}); + addEventListener('hashchange', scheduleUpdate, {signal}); + addEventListener('load', scheduleUpdate, {signal}); if (document.fonts) document.fonts.ready.then(scheduleUpdate); update(); @@ -100,12 +120,15 @@ if (!target) return; event.preventDefault(); contents.open = false; - requestAnimationFrame(() => { + cancelAnimationFrame(navigationFrame); + navigationFrame = requestAnimationFrame(() => { + navigationFrame = 0; + if (disposed) return; history.pushState(null, '', link.hash); target.setAttribute('tabindex', '-1'); target.focus({preventScroll: true}); target.scrollIntoView({block: 'start', behavior: 'auto'}); scheduleUpdate(); }); - }); + }, {signal}); })(); diff --git a/static/js/navigation.js b/static/js/navigation.js index 5163264..49548c8 100644 --- a/static/js/navigation.js +++ b/static/js/navigation.js @@ -93,6 +93,7 @@ await updateStyles(nextDocument, signal); if (id !== navigationId) return; + window.__esdocDocsCleanup?.(); window.__esdocWarperCleanup?.(); window.__esdocToolsCleanup?.(); window.__esdocColorsCleanup?.(); diff --git a/tests/docs_lifecycle.test.mjs b/tests/docs_lifecycle.test.mjs new file mode 100644 index 0000000..df62e97 --- /dev/null +++ b/tests/docs_lifecycle.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../static/js/docs.js', import.meta.url), 'utf8'); +const tick = () => new Promise(resolve => setImmediate(resolve)); +const emit = (target, name, properties = {}) => { + const event = new Event(name); + for (const [name, value] of Object.entries(properties)) Object.defineProperty(event, name, { value }); + target.dispatchEvent(event); +}; + +function docs() { + const window = new EventTarget(), frames = new Map(), media = [], observers = [], css = [], pushes = []; + let frameId = 0, currentPage, resolveFonts; + class ResizeObserver { + constructor(callback) { this.callback = callback; observers.push(this); } + observe() {} + disconnect() { this.disconnected = true; } + } + window.ResizeObserver = ResizeObserver; + const context = vm.createContext({ + window, ResizeObserver, AbortController, location: { hash: '' }, innerHeight: 1000, scrollY: 0, + addEventListener: window.addEventListener.bind(window), + requestAnimationFrame: fn => { const id = ++frameId; frames.set(id, fn); return id; }, + cancelAnimationFrame: id => frames.delete(id), + getComputedStyle: () => ({ scrollPaddingTop: '64px' }), + matchMedia: () => { const item = new EventTarget(); item.matches = false; media.push(item); return item; }, + history: { pushState: (...args) => pushes.push(args) }, + document: { + getElementById: id => ({ 'sidebar-all': currentPage.tree, 'sidebar-contents': currentPage.contents, + section: currentPage.heading })[id], + querySelectorAll: selector => selector.startsWith('.sidebar-toc') ? currentPage.links : currentPage.headings, + documentElement: { scrollHeight: 2000, style: { setProperty: (...args) => css.push(args) } }, + fonts: { ready: new Promise(resolve => { resolveFonts = resolve; }) }, + }, + }); + function page(hasHeadings = true) { + const summary = { offsetHeight: 32, focus() {} }; + const tree = new EventTarget(), contents = new EventTarget(); + for (const details of [tree, contents]) { + details.open = false; + details.contains = () => false; + details.querySelector = selector => selector === 'summary' ? summary : null; + details.closest = () => null; + } + const link = { hash: '#section', classList: { add() {}, remove() {} }, setAttribute() {}, removeAttribute() {} }; + const result = { + tree, contents, reads: 0, links: hasHeadings ? [link] : [], + heading: { id: 'section', + getBoundingClientRect() { result.reads++; return { top: 0 }; }, + setAttribute() {}, focus() {}, scrollIntoView() {}, + }, + }; + result.headings = hasHeadings ? [result.heading] : []; + currentPage = result; + vm.runInContext(source, context); + result.reads = 0; + return result; + } + function flush() { + const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(fn => fn()); + } + return { window, page, flush, frames, media, observers, css, pushes, resolveFonts }; +} + +test('repeated article navigation retains only the current scroll handler', () => { + const site = docs(), pages = []; + for (let i = 0; i < 12; i++) pages.push(site.page()); + emit(site.window, 'scroll'); + site.flush(); + assert.ok(pages.at(-1).reads > 0); + assert.ok(pages.slice(0, -1).every(page => page.reads === 0), 'Old articles still inspect their headings'); + assert.ok(site.observers.slice(0, -1).every(observer => observer.disconnected)); +}); + +test('explicit cleanup removes global and media handlers and disconnects the observer', () => { + const site = docs(), page = site.page(); + assert.equal(typeof site.window.__esdocDocsCleanup, 'function'); + site.window.__esdocDocsCleanup(); + for (const name of ['scroll', 'resize', 'hashchange', 'load']) emit(site.window, name); + for (const media of site.media) { media.matches = true; emit(media, 'change'); } + site.flush(); + assert.equal(page.reads, 0); + assert.equal(page.tree.open, false); + assert.equal(page.contents.open, false); + assert.equal(site.observers[0].disconnected, true); + const writes = site.css.length; + site.observers[0].callback(); + assert.equal(site.css.length, writes); +}); + +test('pages without headings still clean up their disclosure handlers and observer', () => { + const site = docs(), page = site.page(false); + assert.equal(typeof site.window.__esdocDocsCleanup, 'function'); + site.window.__esdocDocsCleanup(); + for (const media of site.media) { media.matches = true; emit(media, 'change'); } + assert.equal(page.tree.open, false); + assert.equal(page.contents.open, false); + assert.equal(site.observers[0].disconnected, true); +}); + +test('cleanup cancels scroll frames and prevents deferred font and TOC callbacks', async () => { + const site = docs(), page = site.page(); + emit(site.window, 'scroll'); + emit(page.contents, 'click', { button: 0, target: { closest: () => page.links[0] } }); + assert.ok(site.frames.size > 0); + assert.equal(typeof site.window.__esdocDocsCleanup, 'function'); + site.window.__esdocDocsCleanup(); + site.flush(); + site.resolveFonts(); + await tick(); + assert.equal(site.frames.size, 0); + assert.equal(page.reads, 0); + assert.deepEqual(site.pushes, []); +}); diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs index 98236e4..cf5259c 100644 --- a/tests/navigation.test.mjs +++ b/tests/navigation.test.mjs @@ -63,7 +63,7 @@ function navigation({ holdStyles = false, holdScripts = false } = {}) { CustomEvent: class {}, localStorage: { getItem: () => null }, }); return { - content, document, location, requests, links, scripts, pushes, redirects, + content, document, location, requests, links, scripts, pushes, redirects, window, pop(path) { current = new URL(path, current); events.popstate(); }, click(path) { let prevented = false; @@ -188,3 +188,14 @@ test('an active navigation failure still falls back to the browser', async () => await tick(); assert.deepEqual(nav.redirects, ['https://example.test/docs/unavailable']); }); + +test('article handlers are cleaned up before replacing the page content', async () => { + const nav = navigation(); + let oldContent; + nav.window.__esdocDocsCleanup = () => { oldContent = nav.content.innerHTML; }; + nav.pop('/docs/new'); + nav.respond(0, { name: 'new' }); + await tick(); + assert.equal(oldContent, 'initial'); + assert.equal(nav.content.innerHTML, 'new'); +}); From 986e139aff23b303782b02d5962bd1a47a167247 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:34:17 +0300 Subject: [PATCH 23/27] fix: preserve selected history URLs when page scripts finish late --- static/js/navigation.js | 1 + tests/navigation.test.mjs | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/static/js/navigation.js b/static/js/navigation.js index 49548c8..127382d 100644 --- a/static/js/navigation.js +++ b/static/js/navigation.js @@ -115,6 +115,7 @@ updateNavigation(url); if (pushHistory) history.pushState({}, '', url.href); + else if (location.href !== url.href) history.replaceState(history.state, '', url.href); if (!url.hash) window.scrollTo(0, 0); await loadPageScripts(nextDocument, id); diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs index cf5259c..b8a97f5 100644 --- a/tests/navigation.test.mjs +++ b/tests/navigation.test.mjs @@ -45,7 +45,10 @@ function navigation({ holdStyles = false, holdScripts = false } = {}) { fetch, DOMParser: true, scrollTo() {}, dispatchEvent() {}, addEventListener: (name, fn) => { events[name] = fn; }, }; - const history = { pushState(state, title, url) { pushes.push(url); current = new URL(url); } }; + const history = { + pushState(state, title, url) { pushes.push(url); current = new URL(url); }, + replaceState(state, title, url) { current = new URL(url); }, + }; class DOMParser { parseFromString(text) { const page = JSON.parse(text); @@ -64,6 +67,7 @@ function navigation({ holdStyles = false, holdScripts = false } = {}) { }); return { content, document, location, requests, links, scripts, pushes, redirects, window, + replace(url) { current = new URL(url, current); }, pop(path) { current = new URL(path, current); events.popstate(); }, click(path) { let prevented = false; @@ -159,6 +163,22 @@ test('pending page scripts finish before the next page replaces their DOM', asyn assert.ok(!nav.scripts.some(script => script.src === '/obsolete.js')); }); +test('a finishing script cannot leave its query parameters on the selected history entry', async () => { + const nav = navigation({ holdScripts: true }); + nav.pop('/resources/original/bg'); + nav.respond(0, { name: 'resources', scripts: ['/resources.js'] }); + await tick(); + nav.pop('/docs/latest'); + nav.respond(1, { name: 'latest' }); + await tick(); + // Resource filters use replaceState while their script initializes. + nav.replace('/docs/latest?time=night'); + nav.scripts[0].loaded(); + await tick(); + assert.equal(nav.content.innerHTML, 'latest'); + assert.equal(nav.location.href, 'https://example.test/docs/latest'); +}); + test('ordinary clicked navigation still pushes history and updates the page', async () => { const nav = navigation(); assert.equal(nav.click('/docs/new'), true); From f9fe4e58fbc04f5b0c9de1ebb442a53c25920368 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:38:43 +0300 Subject: [PATCH 24/27] test: verify application regressions in native browsers --- tests/app_regressions_browser.cjs | 141 ++++++++++++++++++++++++++++++ tests/create_tools_fixtures.py | 14 +++ tests/tools_browser.cjs | 38 +++++--- 3 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 tests/app_regressions_browser.cjs diff --git a/tests/app_regressions_browser.cjs b/tests/app_regressions_browser.cjs new file mode 100644 index 0000000..9d2afa3 --- /dev/null +++ b/tests/app_regressions_browser.cjs @@ -0,0 +1,141 @@ +/* Optional native-browser regressions. Start ES Doc and set NODE_PATH to + Playwright; APP_TEST_URL defaults to http://127.0.0.1:8012. */ +const assert = require('node:assert/strict'); +const {chromium} = require('playwright'); +const origin = process.env.APP_TEST_URL || 'http://127.0.0.1:8012'; + +const softNavigate = async (page, path) => { + await page.evaluate(href => { + const link = document.createElement('a'); + link.href = href; + document.body.appendChild(link); + link.click(); + link.remove(); + }, path); + await page.waitForURL(origin + path); + await page.waitForFunction(href => window.__lastRegressionNavigation === href, origin + path); +}; + +(async () => { + const browser = await chromium.launch({headless: true}); + try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(() => { + window.__regressionDocument = 'original'; + window.__regressionListeners = new Set(); + window.__regressionObservers = new Set(); + const originalAdd = window.addEventListener; + window.addEventListener = function (type, callback, options) { + if (options?.signal && ['scroll', 'resize', 'hashchange', 'load'].includes(type)) { + const record = {type}; + window.__regressionListeners.add(record); + options.signal.addEventListener('abort', () => window.__regressionListeners.delete(record), {once: true}); + } + return originalAdd.call(this, type, callback, options); + }; + const OriginalObserver = window.ResizeObserver; + window.ResizeObserver = class extends OriginalObserver { + constructor(callback) { super(callback); window.__regressionObservers.add(this); } + observe(target, options) { this.target = target; super.observe(target, options); } + disconnect() { window.__regressionObservers.delete(this); super.disconnect(); } + }; + originalAdd.call(window, 'esdoc:navigation', event => { + window.__lastRegressionNavigation = event.detail.url; + }); + }); + await page.goto(origin + '/docs/variables'); + await page.waitForFunction(() => typeof window.__esdocDocsCleanup === 'function'); + await page.evaluate(() => window.__regressionDocument = 'retained'); + for (const slug of ['screens', 'videos', 'variables', 'screens', 'videos']) { + await softNavigate(page, '/docs/' + slug); + const live = await page.evaluate(() => ({ + document: window.__regressionDocument, + listeners: window.__regressionListeners.size, + observers: [...window.__regressionObservers].filter(observer => observer.target?.closest('#sidebar-contents')).length, + })); + assert.deepEqual(live, {document: 'retained', listeners: 4, observers: 1}); + } + + // Two real Back actions while the first HTTP request is held open. + let heldRoute, announceRequest; + const started = new Promise(resolve => { announceRequest = resolve; }); + await page.route('**/docs/screens', route => { heldRoute = route; announceRequest(); }); + await page.evaluate(() => history.back()); + await started; + await page.evaluate(() => history.back()); + await page.waitForURL(origin + '/docs/variables'); + await page.waitForFunction(() => document.querySelector('#sidebar-all a[aria-current="page"]') + ?.getAttribute('href') === '/docs/variables'); + await heldRoute.abort().catch(() => {}); + await page.unroute('**/docs/screens'); + assert.equal(new URL(page.url()).pathname, '/docs/variables'); + assert.equal(await page.evaluate(() => window.__regressionDocument), 'retained'); + + await softNavigate(page, '/materials'); + assert.deepEqual(await page.evaluate(() => ({ + listeners: window.__regressionListeners.size, + observers: [...window.__regressionObservers].filter(observer => observer.target?.closest('#sidebar-contents')).length, + })), {listeners: 0, observers: 0}); + + // Pending search results must remain hidden after clearing the field. + let searchRoute, announceSearch; + const searchStarted = new Promise(resolve => { announceSearch = resolve; }); + await page.route('**/api/search?**', route => { searchRoute = route; announceSearch(); }); + await page.locator('#site-search-input').fill('Луна'); + await searchStarted; + await page.locator('#site-search-input').fill(''); + await searchRoute.fulfill({json: [{label: 'Луна', url: '/stale'}]}); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await page.locator('#site-search-results').isHidden(), true); + assert.equal(await page.locator('#site-search-input').getAttribute('aria-expanded'), 'false'); + assert.deepEqual(errors, []); + await page.close(); + console.log('Native browser checks passed: article cleanup, overlapping Back actions and dismissed search.'); + + // A small valid WAV exercises real Audio playback with both stores denied. + const samples = 8000 * 8, wav = Buffer.alloc(44 + samples * 2); + wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8); + wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); + wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(samples * 2, 40); + for (const failure of ['getter', 'methods']) { + const playerPage = await browser.newPage(); + const playerErrors = []; + playerPage.on('pageerror', error => playerErrors.push(error.message)); + await playerPage.addInitScript(failure => { + for (const name of ['localStorage', 'sessionStorage']) Object.defineProperty(window, name, { + configurable: true, + get() { + if (failure === 'getter') throw new DOMException('Storage denied', 'SecurityError'); + return {getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }, + removeItem() { throw new Error('denied'); }}; + }, + }); + }, failure); + await playerPage.route('**/regression.wav', route => route.fulfill({body: wav, contentType: 'audio/wav'})); + await playerPage.goto(origin + '/'); + await playerPage.evaluate(() => { + const button = document.createElement('button'); + button.id = 'regression-play'; button.hidden = true; + button.dataset.playSrc = '/regression.wav'; button.dataset.playName = 'Regression audio'; + button.textContent = 'Play'; document.body.appendChild(button); + window.dispatchEvent(new CustomEvent('esdoc:navigation')); + }); + await playerPage.locator('#regression-play').click(); + await playerPage.waitForFunction(() => document.querySelector('#regression-play').getAttribute('aria-pressed') === 'true'); + assert.equal(await playerPage.locator('.res-nowplaying').isVisible(), true); + await playerPage.locator('.res-volume input').evaluate(input => { + input.value = '0.35'; input.dispatchEvent(new Event('input', {bubbles: true})); + }); + assert.equal(await playerPage.locator('.res-volume input').inputValue(), '0.35'); + await playerPage.locator('.res-nowplaying-pause').click(); + await playerPage.waitForFunction(() => document.querySelector('#regression-play').getAttribute('aria-pressed') === 'false'); + assert.equal(await playerPage.locator('#regression-play').getAttribute('aria-pressed'), 'false'); + assert.deepEqual(playerErrors, []); + await playerPage.close(); + } + console.log('Native audio checks passed with storage getters and methods denied.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/create_tools_fixtures.py b/tests/create_tools_fixtures.py index b396293..e7add2e 100644 --- a/tests/create_tools_fixtures.py +++ b/tests/create_tools_fixtures.py @@ -1,5 +1,7 @@ """Synthetic Ren'Py/RPA fixtures for the optional real-browser smoke test.""" from pathlib import Path +import pickle +import zlib from tests.test_tools_engine import EngineTests, archive @@ -17,6 +19,18 @@ def main(): (root / 'bad.rpyc').write_bytes(b'not a compiled RenPy script') (root / 'images.rpa').write_bytes(archive({'images/test.txt': [(b'second archive', b'')]})) + # Seek over a large payload so fixture creation does not allocate it in RAM. + length, key = 65 * 1024 * 1024, 0xDEADBEEF + index_offset = 34 + length + 2 + with (root / 'large.rpa').open('wb') as output: + output.write(f'RPA-3.0 {index_offset:016x} {key:08x}\n'.encode()) + output.seek(34 + length) + output.write(b'ok') + output.write(zlib.compress(pickle.dumps({ + 'big.bin': [(34 ^ key, length ^ key, b'')], + 'small.txt': [((34 + length) ^ key, 2 ^ key, b'')], + }, protocol=2))) + if __name__ == '__main__': main() diff --git a/tests/tools_browser.cjs b/tests/tools_browser.cjs index 879b364..6ec19a5 100644 --- a/tests/tools_browser.cjs +++ b/tests/tools_browser.cjs @@ -1,13 +1,13 @@ /* Optional integration test: run create_tools_fixtures first, start ES Doc, set NODE_PATH to your installed Playwright, then node tests/tools_browser.cjs. - TOOLS_TEST_URL defaults to http://127.0.0.1:8012/tools. */ + TOOLS_TEST_URL defaults to http://127.0.0.1:8012/tools/unpack. */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); const root = path.resolve(__dirname, '..'); const fixtures = path.join(root, 'temp/tools-fixtures'); -const target = process.env.TOOLS_TEST_URL || 'http://127.0.0.1:8012/tools'; +const target = process.env.TOOLS_TEST_URL || 'http://127.0.0.1:8012/tools/unpack'; const origin = new URL(target).origin; (async () => { @@ -15,6 +15,8 @@ const origin = new URL(target).origin; try { const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, colorScheme: 'light' }); const errors = [], requests = []; + let batchDownloads = []; + page.on('download', download => batchDownloads.push(download)); page.on('pageerror', error => errors.push(error.message)); page.on('request', request => requests.push([request.method(), request.url()])); await page.goto(target); @@ -35,19 +37,22 @@ const origin = new URL(target).origin; await page.keyboard.press('Escape'); const finish = async () => { - await page.locator('#tools-download-all').click(); + batchDownloads = []; + const download = page.waitForEvent('download', {timeout: 90000}); + await page.locator('#tools-export-start').click(); await page.locator('#tools-result').waitFor({ state: 'visible', timeout: 90000 }); await page.waitForFunction(() => document.querySelector('#tools-downloads a') && document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false', undefined, { timeout: 90000 }); + await download; }; const save = async name => { - const promise = page.waitForEvent('download'); - await page.locator('#tools-downloads a').first().click(); - await (await promise).saveAs(path.join(root, 'temp/' + name)); + assert(batchDownloads.length, 'Export should start a download automatically'); + await batchDownloads[0].saveAs(path.join(root, 'temp/' + name)); assert.equal(await page.locator('#tools-queue').isVisible(), (await page.locator('#tools-result-description').textContent()).includes('Ошибок:')); }; const clear = async () => { + if (await page.locator('#tools-export-dialog').isVisible()) await page.locator('#tools-export-close').click(); if (await page.locator('#tools-clear').isVisible()) await page.locator('#tools-clear').click(); }; @@ -69,7 +74,6 @@ const origin = new URL(target).origin; // A warm worker processes a recursive directory and restores paths. await clear(); - await page.locator('input[value="unrpyc"]').check(); await pick('folder', path.join(fixtures, 'game/scenario')); await page.locator('#tools-browser-search').fill('script.rpyc'); assert.match(await page.locator('#tools-browser-list').textContent(), /scenario\/script.rpyc/); @@ -77,7 +81,6 @@ const origin = new URL(target).origin; await save('tools-browser-folder.zip'); await clear(); - await page.locator('input[value="unrpa"]').check(); await pick('files', path.join(fixtures, 'game/data.rpa')); await page.waitForFunction(() => document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false'); assert.equal(await page.locator('#tools-downloads a').count(), 0); @@ -91,11 +94,21 @@ const origin = new URL(target).origin; await page.waitForFunction(() => document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false'); await finish(); assert.deepEqual(await page.locator('#tools-downloads a').evaluateAll(links => links.map(link => link.download)), ['data.zip', 'images.zip']); - assert(await page.locator('#tools-download-all').isVisible()); + assert.equal(await page.locator('#tools-download-all').isVisible(), false); + + // Listing remains usable after a real Pyodide preview hits its size cap. + await clear(); + await pick('files', path.join(fixtures, 'large.rpa')); + await page.waitForFunction(() => document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false'); + await page.locator('#tools-browser-search').fill(''); + await page.locator('#tools-browser-list button[data-entry-id]').filter({hasText: 'big.bin'}).click(); + await page.waitForFunction(() => document.querySelector('#tools-browser-preview').textContent.includes('Скачайте архив')); + await page.locator('#tools-browser-list button[data-entry-id]').filter({hasText: 'small.txt'}).click(); + await page.locator('#tools-browser-preview pre').waitFor({state: 'visible'}); + assert.equal((await page.locator('#tools-browser-preview .code-scroll').textContent()).trim(), 'ok'); // Drop through a real DataTransfer, alongside a corrupt input. await clear(); - await page.locator('input[value="unrpa"]').check(); const script = [...fs.readFileSync(path.join(fixtures, 'game/scenario/script.rpyc'))]; await page.evaluate(bytes => { const transfer = new DataTransfer(); @@ -103,17 +116,20 @@ const origin = new URL(target).origin; transfer.items.add(new File(['invalid'], 'bad.rpyc')); document.getElementById('tools-drop').dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); }, script); - assert(await page.locator('input[value="unrpyc"]').isChecked()); + assert(await page.locator('#tools-mode-unpack').isChecked()); await finish(); assert.match(await page.locator('#tools-result-description').textContent(), /Ошибок: 1/); await save('tools-browser-partial.zip'); // Soft navigation must clean up the worker and initialize exactly once. + await page.locator('#tools-export-close').click(); await page.locator('.site-header a[href="/materials"]').click(); await page.waitForURL('**/materials'); assert.equal(await page.evaluate(() => window.__esdocToolsCleanup), null); await page.locator('.site-header a[href="/tools"]').click(); await page.waitForURL('**/tools'); + await page.locator('#site-content a[href="/tools/unpack"]').click(); + await page.waitForURL('**/tools/unpack'); await page.locator('#tools-controls').waitFor({ state: 'visible' }); assert.equal(await page.locator('#tools-file-list li').count(), 0); From 815b858abf8e8adea27807ed54d5cbbc2d2a4140 Mon Sep 17 00:00:00 2001 From: somepoi <46904988+somepoi@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:56:48 +0300 Subject: [PATCH 25/27] chore: new tools added --- templates/tools.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/tools.html b/templates/tools.html index 12579a6..7460d01 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -23,6 +23,9 @@

    Онлайн-инструменты