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/ 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..982efa6 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,14 @@ 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 the simple character creator. Only name, dynamic, kind and the who_/what_ color, prefix, suffix (labelled postfix), font, bold, italic, strikethrough and underline properties are editable. Empty names export as None. Daytime game bases come from game/media.rpy; custom kind accepts a character variable and preserves all entered style overrides. Old drafts and imported JSON retain only these properties. The native color chooser and editable HEX support explicit colors; the owner-requested link to /tools/colors opens in a new tab to preserve the draft. + +The preview uses the original daytime dialogue_box.png through the game resource route and locally installed Calibri, the original main_font from screens.rpy. Calibri falls back to sans-serif when unavailable; game font binaries are not included in the assets. Other font paths cannot be previewed. The local specimen font alias ES Dialogue loads Calibri. The game's name color #e1dd7d, dialogue color #ffdd7d, black drop shadow, window and font are specimen data rather than UI tokens. Dynamic scrambles the preview name every 100ms; reduced motion shows a single scrambled value. A visible warning explains that name must identify a variable defined in the game, and that scrambling only represents a dynamic value. The original 1572 × 151 dialogue artwork keeps its aspect ratio. A crop of the camp entrance background and four decorative game control images reproduce the original small-font say screen. Name and dialogue positions follow screens.rpy (20/15 and 20/48 relative to the artwork), with 28px text scaled uniformly. Long dialogue scrolls inside the fixed window rather than stretching its border. A zoom toggle enlarges the specimen to at least 960px and allows horizontal scrolling on narrow viewports. + +Local draft persistence, undo/redo, JSON import/export, Python quoting, field errors, manual clipboard fallback and .rpy export remain. Soft navigation aborts listeners, clears animation timers and releases download URLs. Arbitrary Python modes, engine/window properties, replay and local background/font uploads are removed from this simple surface. + ## Do's and Don'ts ### Do: 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/res.py b/app/routes/res.py index 9c4b86e..5a2c101 100644 --- a/app/routes/res.py +++ b/app/routes/res.py @@ -89,11 +89,11 @@ async def _ensure_tinted(kind, name): if not source: raise HTTPException(404, f'Исходный файл для "{kind} {name}" не существует.') - if not is_tinted(kind, name, source): + if not is_tinted(kind, name, source, item['tint']): async with compose_lock: # Re-check: the request holding the lock before us may have # composed this very image. - if not is_tinted(kind, name, source): + if not is_tinted(kind, name, source, item['tint']): try: await asyncio.to_thread(compose_tint, kind, name, source, item['tint']) except Exception: 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/app/routes/search_discovery.py b/app/routes/search_discovery.py index b32a9f1..e73c44c 100644 --- a/app/routes/search_discovery.py +++ b/app/routes/search_discovery.py @@ -1,9 +1,9 @@ """Crawler entry points for the public pages of the site.""" -from urllib.parse import quote +from urllib.parse import quote, urlsplit, urlunsplit from xml.etree import ElementTree -from fastapi import Response +from fastapi import HTTPException, Response from fastapi.responses import PlainTextResponse from ..utils.config import CONFIG @@ -15,17 +15,40 @@ 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', ) +def _site_origin(): + value = CONFIG.site_url + if not isinstance(value, str) or not value.strip(): + return None + origin = value.strip().rstrip('/') + if any(char.isspace() for char in origin): + return None + try: + parsed = urlsplit(origin) + if (parsed.scheme not in ('http', 'https') or not parsed.hostname + or parsed.username is not None or parsed.password is not None + or parsed.path or parsed.query or parsed.fragment): + return None + parsed.port # Validate the optional port before publishing the URL. + return urlunsplit((parsed.scheme, parsed.netloc, '', '', '')) + except ValueError: + return None + + @main_router.get('/robots.txt', response_class=PlainTextResponse) async def robots_txt(): - return f'User-agent: *\nAllow: /\n\nSitemap: {CONFIG.site_url}/sitemap.xml\n' + origin = _site_origin() + return 'User-agent: *\nAllow: /\n' + (f'\nSitemap: {origin}/sitemap.xml\n' if origin else '') @main_router.get('/sitemap.xml') async def sitemap_xml(): + origin = _site_origin() + if not origin: + raise HTTPException(404, 'Адрес сайта не настроен.') paths = list(STATIC_PAGES) paths.extend(f'/docs/{quote(doc["slug"], safe="")}' for doc in CONFIG.search_index) paths.extend( @@ -39,7 +62,7 @@ async def sitemap_xml(): root = ElementTree.Element(f'{{{SITEMAP_NS}}}urlset') for path in dict.fromkeys(paths): entry = ElementTree.SubElement(root, f'{{{SITEMAP_NS}}}url') - ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = CONFIG.site_url + path + ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = origin + path return Response( content=ElementTree.tostring(root, encoding='utf-8', xml_declaration=True), diff --git a/app/routes/static.py b/app/routes/static.py index 438af54..7c97bbd 100644 --- a/app/routes/static.py +++ b/app/routes/static.py @@ -19,6 +19,19 @@ _VERSIONED_CACHE = 'public, max-age=31536000, immutable' +def _asset_path(folder: str, name: str): + if not name or any(char in name for char in ('/', '\\', ':', '\x00')): + raise HTTPException(404, 'Файл не существует.') + root = (ROOT / 'static' / folder).resolve() + try: + path = (root / name).resolve() + if path.is_relative_to(root) and path.is_file(): + return path + except (OSError, ValueError): + pass + raise HTTPException(404, 'Файл не существует.') + + def _text_asset(request: Request, rel: str, media_type: str) -> Response: try: body, digest = load_asset(rel) @@ -44,10 +57,12 @@ async def favicon(): @router.get('/static/css/{name}') async def css(name, request: Request): + _asset_path('css', name) return _text_asset(request, f'static/css/{name}', 'text/css; charset=utf-8') @router.get('/static/js/{name}') async def js(name, request: Request): + _asset_path('js', name) return _text_asset(request, f'static/js/{name}', 'application/javascript; charset=utf-8') @@ -62,7 +77,7 @@ async def font(name): # Binary asset: FileResponse, not read_text (which decodes as UTF-8). # Immutable, content-hashed by weight/subset, so cache hard. return FileResponse( - str(ROOT / 'static' / 'fonts' / name), + str(_asset_path('fonts', name)), media_type='font/woff2', headers={ 'Cache-Control': 'public, max-age=31536000, immutable', diff --git a/app/routes/tools.py b/app/routes/tools.py index f6a9e5f..e3d4b51 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', @@ -50,11 +50,17 @@ 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', { '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/refresh.py b/app/utils/lifespan/refresh.py index 0d772de..48c129a 100644 --- a/app/utils/lifespan/refresh.py +++ b/app/utils/lifespan/refresh.py @@ -105,6 +105,7 @@ def _watchers(): assets / 'nsfw.yaml', ) community_files = _under(assets / 'community') + game_files = _under(res) materials_yaml = _one_of(assets / 'materials.yaml') materials_files = _under(assets / 'materials') @@ -119,7 +120,7 @@ def _watchers(): res / 'scenario' / 'zhenya.rpy', ), _refresh_sprites), - ('resources', lambda path: resource_sources(path) or community_files(path), _refresh_resources), + ('resources', lambda path: resource_sources(path) or community_files(path) or game_files(path), _refresh_resources), ('browser', _under(res), _refresh_browser), ('specialists.yaml', _one_of(assets / 'specialists.yaml'), _refresh_specialists), diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py index 47ecbd5..304d803 100644 --- a/app/utils/lifespan/search_cache.py +++ b/app/utils/lifespan/search_cache.py @@ -17,8 +17,9 @@ ('Специалисты', '/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'), + ('Создатель простых персонажей / Character / RenPy / Имя / Диалог / Повествование / Стиль текста', '/tools/characters'), ('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'), ('Площадки для публикации', '/news-resources'), ('Авторы', '/authors'), diff --git a/app/utils/lifespan/sprites_cache.py b/app/utils/lifespan/sprites_cache.py index d2b515d..a00cac2 100644 --- a/app/utils/lifespan/sprites_cache.py +++ b/app/utils/lifespan/sprites_cache.py @@ -39,11 +39,18 @@ def sprite_file(name): return sprites_path() / f'{name}.webp' def is_composed(name): - # The disk cache survives restarts; a cached sprite is only stale if - # a declaration source changed after it was composed. + # Both the declaration and every layer contribute to the composed bytes. path = sprite_file(name) - newest = max(p.stat().st_mtime for p in _sprite_sources() if p.exists()) - return path.is_file() and path.stat().st_mtime >= newest + layers = CONFIG.sprite_layers.get(name) + if not path.is_file() or not layers: + return False + sources = [p for p in _sprite_sources() if p.exists()] + sources.extend(CONFIG.res_path / layer for layer in layers) + try: + newest = max(source.stat().st_mtime_ns for source in sources) + return path.stat().st_mtime_ns >= newest + except OSError: + return False def compose_sprite(name): diff --git a/app/utils/lifespan/tint_cache.py b/app/utils/lifespan/tint_cache.py index c2aa85c..995338f 100644 --- a/app/utils/lifespan/tint_cache.py +++ b/app/utils/lifespan/tint_cache.py @@ -1,3 +1,4 @@ +import json import os, time from PIL import Image @@ -12,17 +13,29 @@ def tinted_path(): def tinted_file(kind, name): return tinted_path() / kind / f'{name}.webp' -def is_tinted(kind, name, source): - # The disk cache survives restarts; a cached tint is only stale if the - # source image changed after it was composed. +def _signature(source, tint): + stat = source.stat() + return [str(source.resolve()), stat.st_mtime_ns, stat.st_size, + list(tint), CONFIG.setting('images.tint-quality')] + + +def is_tinted(kind, name, source, tint): + # Persist every input so changes are detected even across restarts. path = tinted_file(kind, name) - return path.is_file() and path.stat().st_mtime >= source.stat().st_mtime + if not path.is_file(): + return False + try: + saved = json.loads(path.with_suffix('.json').read_text('utf-8')) + return saved == _signature(source, tint) + except (OSError, ValueError): + return False def compose_tint(kind, name, source, tint): """Ren'Py's `im.MatrixColor(file, im.matrix.tint(r, g, b))`: a diagonal color matrix that scales the R/G/B channels independently and leaves alpha untouched — exactly a per-channel multiply, no channel mixing.""" r, g, b = tint + signature = _signature(source, tint) starttime = time.time() @@ -47,6 +60,10 @@ def compose_tint(kind, name, source, tint): tmp = path.with_suffix('.webp.tmp') img.save(tmp, 'WEBP', quality=CONFIG.setting('images.tint-quality')) os.replace(tmp, path) + metadata = path.with_suffix('.json') + temporary_metadata = metadata.with_suffix('.json.tmp') + temporary_metadata.write_text(json.dumps(signature), encoding='utf-8') + os.replace(temporary_metadata, metadata) logger.info(f'Tinted image "{kind} {name}" composed in {time.time() - starttime:.4f}s') 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 82d482b..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 @@ -37,6 +36,7 @@ # without the lead text that follows on the same line. Only the name matters # here — what a block does with its lead is the parser's own business. _OPEN_RE = re.compile(r'^:::[ \t]*([A-Za-z][\w-]*)') +_FENCE_RE = re.compile(r'^(`{3,}|~{3,})(.*)$') CLOSER = ':::' @@ -84,18 +84,31 @@ def find_closer(state, startLine: int, endLine: int) -> int | None: """ depth = 0 line = startLine + 1 + fence = None while line < endLine: # bMarks + tShift skips the indent, so a closer indented inside a list # item still reads as a closer — which the corpus relies on. text = state.src[state.bMarks[line] + state.tShift[line]:state.eMarks[line]].strip() - if text == CLOSER: + marker = _FENCE_RE.match(text) + if fence: + if (marker and marker[1][0] == fence[0] + and len(marker[1]) >= len(fence) and not marker[2].strip()): + fence = None + elif marker and (marker[1][0] != '`' or '`' not in marker[2]): + fence = marker[1] + elif text == CLOSER: 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/scripts/prepare_character_catalog.py b/scripts/prepare_character_catalog.py new file mode 100644 index 0000000..e8f345a --- /dev/null +++ b/scripts/prepare_character_catalog.py @@ -0,0 +1,32 @@ +"""Refresh the simple creator's base characters from the original media.rpy. + +Run: python scripts/prepare_character_catalog.py /path/to/game/media.rpy +The constructor's small property whitelist is maintained in characters-core.js. +""" +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LABELS = {'voice': 'Голос', 'me': 'Семён', 'my': 'Я', 'narrator': 'Повествование', 'th': 'Мысли', 'el': 'Электроник', 'elp': 'elp', 'ro': 'Роутер', 'un': 'Лена', 'unp': 'unp', 'dv': 'Алиса', 'dvp': 'dvp', 'dvg': 'dvg', 'sl': 'Славя', 'slp': 'slp', 'slg': 'slg', 'sa': 'Саша', 'us': 'Ульяна', 'usp': 'usp', 'usg': 'usg', 'mt': 'Ольга Дмитриевна', 'mtp': 'mtp', 'cs': 'Виола', 'csp': 'csp', 'mz': 'Женя', 'mzp': 'mzp', 'mi': 'Мику', 'mip': 'mip', 'ma': 'Маша', 'uv': 'Юля', 'uvp': 'uvp', 'sh': 'Шурик', 'shp': 'shp', 'pi': 'Пионер', 'all': 'Все', 'dreamgirl': 'dreamgirl', 'bush': 'bush', 'FIXME_voice': 'FIXME_voice', 'odn': 'odn', 'message': 'message', 'mt_voice': 'mt_voice'} + + +def generate(source): + colors = {} + for key, rgba in re.findall(r"colors\[\s*'([^']+)'\].*?'day':\s*\(([^)]+)\)", source): + colors[key] = '#' + ''.join(f'{int(channel.strip()):02x}' for channel in rgba.split(',')[:3]) + keys = list(dict.fromkeys(re.findall(r"names_list.append\('([^']+)'\)", source))) + if not keys or 'me' not in keys or 'narrator' not in keys: + raise ValueError('Incomplete game character definitions') + bases = [{'key': key, 'label': LABELS.get(key, key), 'color': colors.get(key, '#e1dd7d')} for key in keys] + output = '/* Daytime base characters from game/media.rpy. */\n' + output += 'window.ESDocCharacterCatalog = ' + json.dumps({'bases': bases}, ensure_ascii=False, indent=2) + ';\n' + (ROOT / 'static/js/characters-catalog.js').write_text(output, 'utf-8') + print(f'{len(bases)} game bases written') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + raise SystemExit('Pass the path to game/media.rpy') + generate(Path(sys.argv[1]).read_text('utf-8')) diff --git a/static/css/characters.css b/static/css/characters.css new file mode 100644 index 0000000..102b620 --- /dev/null +++ b/static/css/characters.css @@ -0,0 +1,116 @@ +.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; } +@font-face { font-family: "ES Dialogue"; src: local("Calibri"); font-weight: 400; } +@font-face { font-family: "ES Dialogue"; src: local("Calibri Bold"); font-weight: 700; } +@font-face { font-family: "ES Dialogue"; src: local("Calibri Italic"); font-style: italic; } +@font-face { font-family: "ES Dialogue"; src: local("Calibri Bold Italic"); font-style: italic; font-weight: 700; } +.character-preview-viewport { overflow-x: auto; overscroll-behavior-x: contain; border-radius: var(--radius-sm); } +.character-stage { position: relative; width: 100%; aspect-ratio: 1572 / 151; container-type: inline-size; background: #253846 url('/resource/raw/images/bg/ext_camp_entrance_day.jpg') 50% 98.600646% / 122.137405% auto no-repeat; } +.character-preview-viewport.is-zoomed .character-stage { width: max(100%, 960px); } +.character-dialogue-window { position: absolute; inset: 0; background: url('/resource/raw/images/gui/dialogue_box/day/dialogue_box.png') center / 100% 100% no-repeat; color: #ffdd7d; font-family: "ES Dialogue", Calibri, sans-serif; } +.character-namebox { position: absolute; left: 1.272265%; top: 9.933775%; width: 83%; } +#character-preview-who, #character-preview-what { font-size: 1.78117cqw; font-weight: 400; line-height: 1.071429; text-shadow: .127226cqw .127226cqw #000; white-space: pre-wrap; overflow-wrap: anywhere; } +#character-preview-who { color: #e1dd7d; white-space: pre; overflow: hidden; } +#character-preview-what { position: absolute; left: 1.272265%; top: 31.78808%; width: 98.02799%; bottom: 2.649007%; overflow: auto; scrollbar-width: thin; } +.character-dialogue-controls { position: absolute; inset: 0; pointer-events: none; } +.character-dialogue-controls img { position: absolute; top: 11.258278%; width: 1.717557%; height: auto; } +.character-dialogue-controls img:nth-child(1) { left: 84.860051%; } +.character-dialogue-controls img:nth-child(2) { left: 88.613232%; } +.character-dialogue-controls img:nth-child(3) { left: 92.302799%; } +.character-dialogue-controls img:nth-child(4) { left: 95.928753%; } +.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; } + .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/css/colors.css b/static/css/colors.css index 48b8dfc..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,6 +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: 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 db93d21..1bb5370 100644 --- a/static/css/tools.css +++ b/static/css/tools.css @@ -1,17 +1,68 @@ .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(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; } .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); } .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 +82,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 +97,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); } @@ -161,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/characters-catalog.js b/static/js/characters-catalog.js new file mode 100644 index 0000000..40db09f --- /dev/null +++ b/static/js/characters-catalog.js @@ -0,0 +1,210 @@ +/* Daytime base characters from game/media.rpy. */ +window.ESDocCharacterCatalog = { + "bases": [ + { + "key": "voice", + "label": "Голос", + "color": "#e1dd7d" + }, + { + "key": "me", + "label": "Семён", + "color": "#e1dd7d" + }, + { + "key": "my", + "label": "Я", + "color": "#e1dd7d" + }, + { + "key": "narrator", + "label": "Повествование", + "color": "#e1dd7d" + }, + { + "key": "th", + "label": "Мысли", + "color": "#e1dd7d" + }, + { + "key": "el", + "label": "Электроник", + "color": "#ffff00" + }, + { + "key": "elp", + "label": "elp", + "color": "#ffff00" + }, + { + "key": "ro", + "label": "Роутер", + "color": "#ffff00" + }, + { + "key": "un", + "label": "Лена", + "color": "#b956ff" + }, + { + "key": "unp", + "label": "unp", + "color": "#b956ff" + }, + { + "key": "dv", + "label": "Алиса", + "color": "#ffaa00" + }, + { + "key": "dvp", + "label": "dvp", + "color": "#ffaa00" + }, + { + "key": "dvg", + "label": "dvg", + "color": "#ffaa00" + }, + { + "key": "sl", + "label": "Славя", + "color": "#ffd200" + }, + { + "key": "slp", + "label": "slp", + "color": "#ffd200" + }, + { + "key": "slg", + "label": "slg", + "color": "#ffd200" + }, + { + "key": "sa", + "label": "Саша", + "color": "#ffd200" + }, + { + "key": "us", + "label": "Ульяна", + "color": "#ff3200" + }, + { + "key": "usp", + "label": "usp", + "color": "#ff3200" + }, + { + "key": "usg", + "label": "usg", + "color": "#ff3200" + }, + { + "key": "mt", + "label": "Ольга Дмитриевна", + "color": "#00ea32" + }, + { + "key": "mtp", + "label": "mtp", + "color": "#00ea32" + }, + { + "key": "cs", + "label": "Виола", + "color": "#a5a5ff" + }, + { + "key": "csp", + "label": "csp", + "color": "#a5a5ff" + }, + { + "key": "mz", + "label": "Женя", + "color": "#4a86ff" + }, + { + "key": "mzp", + "label": "mzp", + "color": "#4a86ff" + }, + { + "key": "mi", + "label": "Мику", + "color": "#00deff" + }, + { + "key": "mip", + "label": "mip", + "color": "#00deff" + }, + { + "key": "ma", + "label": "Маша", + "color": "#00deff" + }, + { + "key": "uv", + "label": "Юля", + "color": "#4eff00" + }, + { + "key": "uvp", + "label": "uvp", + "color": "#4eff00" + }, + { + "key": "sh", + "label": "Шурик", + "color": "#fff226" + }, + { + "key": "shp", + "label": "shp", + "color": "#fff226" + }, + { + "key": "pi", + "label": "Пионер", + "color": "#e60101" + }, + { + "key": "all", + "label": "Все", + "color": "#ed4444" + }, + { + "key": "dreamgirl", + "label": "dreamgirl", + "color": "#c0c0c0" + }, + { + "key": "bush", + "label": "bush", + "color": "#c0c0c0" + }, + { + "key": "FIXME_voice", + "label": "FIXME_voice", + "color": "#c0c0c0" + }, + { + "key": "odn", + "label": "odn", + "color": "#c0c0c0" + }, + { + "key": "message", + "label": "message", + "color": "#c0c0c0" + }, + { + "key": "mt_voice", + "label": "mt_voice", + "color": "#00ea32" + } + ] +}; diff --git a/static/js/characters-core.js b/static/js/characters-core.js new file mode 100644 index 0000000..166b284 --- /dev/null +++ b/static/js/characters-core.js @@ -0,0 +1,146 @@ +/* Typed Character export. User Python is stored as text, never executed. */ +(function () { + 'use strict'; + const args = [ + {key:'kind', label:'Основа персонажа', mode:'expression', group:'identity'}, + {key:'dynamic', label:'Динамическое имя', mode:'boolean', group:'identity'}, + ]; + const labels = {color:'Цвет', prefix:'Префикс', suffix:'Постфикс', font:'Шрифт', bold:'Полужирный', italic:'Курсив', strikethrough:'Перечёркивание', underline:'Подчёркивание'}; + const fields = [...args]; + for (const group of ['who','what']) for (const [prop,label] of Object.entries(labels)) { + fields.push({key:group + '_' + prop, label, group, mode:['bold','italic','strikethrough','underline'].includes(prop) ? 'boolean' : 'text'}); + } + const byKey = new Map(fields.map(item => [item.key,item])); + function simpleState(state) { + const values = Object.create(null); + for (const [key,entry] of Object.entries(state.values || {})) { + const field = byKey.get(key); + if (field && entry.mode === field.mode && (field.mode !== 'boolean' || ['True','False'].includes(entry.value))) values[key] = {...entry}; + } + if (!values.dynamic) values.dynamic = {mode:'boolean',value:'False'}; + return {...state, name:state.nameMode === 'none' ? '' : state.name, nameMode:'text', values}; + } + 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(state.name === '' ? 'None' : 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 = {simpleState, 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..75e9f29 --- /dev/null +++ b/static/js/characters.js @@ -0,0 +1,134 @@ +(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(); + const on = (el,event,fn) => el.addEventListener(event,fn,{signal:controller.signal}); + const defaults = () => ({variable:'e',name:'Семён',nameMode:'text',sample:'Я говорю так! Добро пожаловать в «Совёнок».',values:{dynamic:{mode:'boolean',value:'False'}}}); + const clone = s => JSON.parse(JSON.stringify(s)), storageKey = 'es-doc-character-draft-v1'; + const rows = new Map(), undos = [], redos = [], urls = new Set(), bases = window.ESDocCharacterCatalog.bases; + let state = defaults(), committed, disposed = false, timer, scrambleTimer; + const status = message => { if (!disposed) get('status').textContent = message; }; + try { + const saved = localStorage.getItem(storageKey); + if (saved) { state = c.simpleState(c.importProject(saved)); get('storage-note').textContent = 'Черновик восстановлен. Доступны только свойства простого персонажа.'; } + } catch (_) { get('storage-note').textContent = 'Не удалось прочитать черновик. Сохраните настройки в файл.'; } + committed = clone(state); + const element = (tag,text) => { const el = document.createElement(tag); if (text !== undefined) el.textContent = text; return el; }; + const option = (select,value,label) => { const el = element('option',label); el.value = value; select.append(el); }; + for (const base of bases) option(get('kind'),base.key,base.label + ' / ' + base.key); + option(get('kind'),'custom','Кастомная основа'); + for (const field of c.fields.filter(field => ['who','what'].includes(field.group))) { + const row = element('div'); row.className = 'character-field'; + const label = element('label',field.label + ' / ' + field.key); label.htmlFor = 'character-value-' + field.key; + const input = element(field.mode === 'boolean' ? 'select' : 'input'); input.id = label.htmlFor; + if (field.mode === 'boolean') { option(input,'','По умолчанию'); option(input,'True','Да'); option(input,'False','Нет'); } + else { input.type = 'text'; input.maxLength = 20000; input.placeholder = 'По умолчанию'; input.autocomplete = 'off'; } + const error = element('p'); error.className = 'character-error'; error.id = 'character-error-' + field.key; error.hidden = true; + input.setAttribute('aria-describedby',error.id); row.append(label); + let picker; + if (field.key.endsWith('_color')) { + const controls = element('div'); controls.className = 'character-field-value'; + picker = element('input'); picker.type = 'color'; picker.id = 'character-color-' + field.key; + picker.setAttribute('aria-label','Выбрать цвет / ' + field.key); picker.className = 'character-color'; + controls.append(input,picker); row.append(controls); + on(picker,'input',() => { input.value = picker.value; state.values[field.key] = {mode:'text',value:picker.value}; edit(); }); on(picker,'change',remember); + } else row.append(input); + if (field.key.endsWith('_font')) { + const help = element('p','Путь в game, например fonts/my-font.ttf. Другой шрифт невозможно предсмотреть в браузере.'); + help.className = 'character-field-help'; help.id = 'character-help-' + field.key; row.append(help); input.setAttribute('aria-describedby',help.id + ' ' + error.id); + } + if (field.key.endsWith('_suffix')) input.title = 'Постфикс передаётся в Ren\'Py как ' + field.key; + row.append(error); get(field.group + '-fields').append(row); rows.set(field.key,{input,picker,error}); + on(input,field.mode === 'boolean' ? 'change' : 'input',() => { if (input.value === '') delete state.values[field.key]; else state.values[field.key] = {mode:field.mode,value:input.value}; edit(field.mode === 'boolean'); }); + on(input,'change',remember); + } + function remember() { + clearTimeout(timer); + if (JSON.stringify(state) !== JSON.stringify(committed)) { undos.push(committed); if (undos.length > 64) undos.shift(); committed = clone(state); redos.length = 0; } + get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; + } + function persist() { + try { localStorage.setItem(storageKey,c.project(state)); get('storage-note').textContent = 'Черновик сохранён в этом браузере.'; } + catch (_) { get('storage-note').textContent = 'Не удалось сохранить черновик. Используйте «Сохранить настройки».'; } + } + function edit(immediate = false) { render(); persist(); clearTimeout(timer); if (immediate) remember(); else timer = setTimeout(remember,450); } + const value = (key,fallback) => { const entry = state.values[key]; if (!entry) return fallback; return entry.mode === 'boolean' ? entry.value === 'True' : entry.value; }; + function scramble() { + const chars = 'АБВГДЖЗИЙКЛМНОПРСТУФХЦЧШЩЭЮЯ0123456789!?%#'; + get('preview-who').textContent = value('who_prefix','') + Array.from(state.name || 'Имя',ch => /\s/u.test(ch) ? ch : chars[Math.floor(Math.random()*chars.length)]).join('') + value('who_suffix',''); + } + function preview() { + clearInterval(scrambleTimer); + const base = bases.find(base => base.key === state.values.kind?.value); + for (const prefix of ['who','what']) { + const target = get('preview-' + prefix); target.removeAttribute('style'); + const color = value(prefix + '_color',prefix === 'who' ? base?.color || '#e1dd7d' : '#ffdd7d'); + if (/^#(?:[a-f\d]{3,4}|[a-f\d]{6}|[a-f\d]{8})$/i.test(color)) target.style.color = color; + target.style.fontWeight = value(prefix + '_bold',false) ? '700' : '400'; target.style.fontStyle = value(prefix + '_italic',false) ? 'italic' : 'normal'; + target.style.textDecoration = [value(prefix + '_underline',false) ? 'underline' : '',value(prefix + '_strikethrough',false) ? 'line-through' : ''].filter(Boolean).join(' ') || 'none'; + } + get('namebox').hidden = state.name === ''; + get('preview-who').textContent = value('who_prefix','') + state.name + value('who_suffix',''); + get('preview-what').textContent = value('what_prefix',base?.key === 'th' ? '~ ' : '') + state.sample + value('what_suffix',base?.key === 'th' ? ' ~' : ''); + if (value('dynamic',false) && state.name !== '') { scramble(); if (!matchMedia('(prefers-reduced-motion: reduce)').matches) scrambleTimer = setInterval(scramble,100); } + } + function render() { + get('dynamic-warning').hidden = !value('dynamic',false); get('custom-kind-control').hidden = get('kind').value !== 'custom'; + for (const id of ['variable','name','kind']) { get(id + '-error').hidden = true; (id === 'kind' ? get('custom-kind') : get(id)).removeAttribute('aria-invalid'); } + for (const [key,row] of rows) { + row.error.hidden = true; row.input.removeAttribute('aria-invalid'); + if (row.picker) { + const hex = value(key,key.startsWith('who') ? bases.find(base => base.key === state.values.kind?.value)?.color || '#e1dd7d' : '#ffdd7d'); + if (/^#[a-f\d]{3,4}$/i.test(hex)) row.picker.value = '#' + hex.slice(1,4).split('').map(ch => ch+ch).join(''); + else if (/^#[a-f\d]{6}(?:[a-f\d]{2})?$/i.test(hex)) row.picker.value = hex.slice(0,7); + } + } + const result = c.compile(state); + if (state.values.kind && !c.identifier(state.values.kind.value)) result.errors.push({key:'kind',message:'Введите имя переменной персонажа: буквы, цифры и _, без пробелов.'}); + for (const [key] of rows) if (key.endsWith('_color') && state.values[key] && !/^#(?:[a-f\d]{3,4}|[a-f\d]{6}|[a-f\d]{8})$/i.test(state.values[key].value)) result.errors.push({key,message:'Введите HEX-цвет, например #aabbcc или #aabbccdd.'}); + get('code').value = result.errors.length ? 'Исправьте отмеченные поля, чтобы получить код.' : result.code; + get('code').rows = Math.max(4,Math.min(13,result.code.split('\n').length+1)); get('copy').disabled = get('download').disabled = Boolean(result.errors.length); + get('validation').hidden = !result.errors.length; get('validation').textContent = 'Ошибок в настройках: ' + result.errors.length + '.'; + get('fix-error').hidden = !result.errors.length; get('fix-error').dataset.key = result.errors[0]?.key || ''; + for (const error of result.errors) { + const row = rows.get(error.key), input = row?.input || (error.key === 'kind' ? get('custom-kind') : get(error.key)); + const hint = row?.error || get(error.key + '-error'); input?.setAttribute('aria-invalid','true'); if (hint) { hint.hidden = false; hint.textContent = error.message; } + } + get('usage').textContent = (c.identifier(state.variable) ? state.variable : 'e') + ' "Привет!"'; preview(); + } + function sync() { + for (const key of ['variable','name','sample']) get(key).value = state[key]; + const kind = state.values.kind?.value || ''; get('kind').value = !kind || bases.some(base => base.key === kind) ? kind : 'custom'; + get('custom-kind').value = get('kind').value === 'custom' ? kind : ''; get('dynamic').checked = value('dynamic',false); + for (const [key,row] of rows) row.input.value = state.values[key]?.value || ''; render(); + } + for (const key of ['variable','name','sample']) { on(get(key),'input',() => { state[key] = get(key).value; edit(); }); on(get(key),'change',remember); } + on(get('kind'),'change',() => { const kind = get('kind').value === 'custom' ? get('custom-kind').value : get('kind').value; if (get('kind').value) state.values.kind = {mode:'expression',value:kind}; else delete state.values.kind; edit(true); }); + on(get('custom-kind'),'input',() => { state.values.kind = {mode:'expression',value:get('custom-kind').value}; edit(); }); + on(get('dynamic'),'change',() => { state.values.dynamic = {mode:'boolean',value:get('dynamic').checked ? 'True' : 'False'}; edit(true); }); + on(get('zoom'),'click',() => { + const zoomed = get('preview-viewport').classList.toggle('is-zoomed'); + get('zoom').setAttribute('aria-pressed',String(zoomed)); + get('zoom').textContent = zoomed ? 'Исходный размер' : 'Увеличить'; + get('preview-viewport').scrollLeft = 0; + }); + on(get('settings'),'submit',event => event.preventDefault()); + function undo() { remember(); if (!undos.length) return; redos.push(clone(state)); state = undos.pop(); committed = clone(state); persist(); sync(); get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; } + function redo() { if (!redos.length) return; undos.push(clone(state)); state = redos.pop(); committed = clone(state); persist(); sync(); get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; } + on(get('undo'),'click',undo); on(get('redo'),'click',redo); + on(document,'keydown',event => { if (!(event.ctrlKey || event.metaKey) || event.altKey || event.target.matches('input,textarea,select,[contenteditable]')) return; if (event.key.toLowerCase() === 'z') { event.preventDefault(); event.shiftKey ? redo() : undo(); } else if (event.key.toLowerCase() === 'y') { event.preventDefault(); redo(); } }); + on(get('reset'),'click',() => { remember(); state = defaults(); persist(); sync(); remember(); status('Настройки сброшены. Их можно вернуть кнопкой «Отменить».'); }); + on(get('fix-error'),'click',() => { const key = get('fix-error').dataset.key, input = rows.get(key)?.input || (key === 'kind' ? get('custom-kind') : get(key)); for (let el = input?.parentElement; el; el = el.parentElement) if (el.tagName === 'DETAILS') el.open = true; input?.focus(); }); + on(get('copy'),'click',async () => { if (get('copy').disabled) return; try { await navigator.clipboard.writeText(get('code').value); 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})); urls.add(url); const link = element('a'); link.href = url; link.download = name; document.body.append(link); link.click(); link.remove(); setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url); },1000); } + on(get('download'),'click',() => { if (!get('download').disabled) download(state.variable + '.rpy',get('code').value + '\n','text/plain;charset=utf-8'); }); + on(get('save-project'),'click',() => download((c.identifier(state.variable) ? state.variable : 'character') + '.character.json',c.project(state),'application/json')); + 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.simpleState(c.importProject(await file.text())); if (disposed) return; remember(); state = loaded; sync(); persist(); remember(); status('Настройки открыты.'); } catch (error) { status(error.message); } finally { if (!disposed) get('project-file').value = ''; } }); + for (const button of app.querySelectorAll('[data-character-jump]')) on(button,'click',() => get(button.dataset.characterJump + '-heading')?.focus()); + window.__esdocCharactersCleanup = () => { remember(); disposed = true; controller.abort(); clearTimeout(timer); clearInterval(scrambleTimer); for (const url of urls) URL.revokeObjectURL(url); window.__esdocCharactersCleanup = null; }; + sync(); app.hidden = false; +})(); diff --git a/static/js/colors.js b/static/js/colors.js index 6639a95..7df241f 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,20 +437,109 @@ 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) { - get('eyedropper').hidden = false; + on(get('eyedropper-overlay'), 'pointerdown', event => { + if (!screenCaptureVideo || event.button !== 0 || get('eyedropper-overlay').hidden) return; + const sampleCanvas = get('screen-canvas'); + if (event.target !== sampleCanvas) return; + event.preventDefault(); + try { + const rect = sampleCanvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const context = sampleCanvas.getContext('2d', {willReadFrequently: true}); + 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 = 'Не удалось прочитать цвет снимка. Попробуйте снова или откройте изображение.'; + } + }); + 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(); eyedropperController = pickController; 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 = 'Выберите экран или окно для снимка.'; + 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 — отмена.'; + 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); + 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}); } + } + }); + } + if (!nativeEyedropper && !screenEyedropper) { + on(get('eyedropper'), 'click', () => { + get('status').textContent = 'Пипетка недоступна в вашем браузере.'; }); } const canvas = get('image-canvas'); @@ -521,7 +610,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/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 5c306e7..127382d 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,39 +88,16 @@ } }; - 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.__esdocDocsCleanup?.(); window.__esdocWarperCleanup?.(); window.__esdocToolsCleanup?.(); window.__esdocColorsCleanup?.(); + window.__esdocCharactersCleanup?.(); window.__esdocAnimationsCleanup?.(); content.innerHTML = nextContent.innerHTML; document.title = nextDocument.title; @@ -128,9 +115,10 @@ 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); + await loadPageScripts(nextDocument, id); if (id !== navigationId) return; // Soft navigation replaces the document without the browser's native @@ -147,6 +135,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; @@ -159,16 +181,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/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/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/static/js/tools-core.js b/static/js/tools-core.js index 1a76dc7..e63025b 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('Укажите относительный путь папки без .., начального / и двоеточия. Например: 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) { @@ -88,6 +117,9 @@ } else if (/Failed to fetch|NetworkError|Load failed|dynamically imported module|Не удалось загрузить инструменты/i.test(normalized)) { title = 'Не удалось загрузить инструменты'; explanation = 'Проверьте соединение с сайтом и повторите действие. Файлы остаются на вашем устройстве.'; + } else if (/WebAssembly|CompileError|wasm.*(?:unsupported|not supported)|Браузер не поддерживает/i.test(normalized)) { + title = 'Браузер не смог запустить обработку'; + explanation = 'Для локальной обработки нужен доступный WebAssembly. Обновите браузер или попробуйте актуальный Firefox, Chrome или Edge. Python устанавливать не нужно.'; } else if (/UnknownArchive|unsupported archive|archive.*(?:unknown|unsupported)|неподдерживаем.+архив/i.test(normalized)) { title = 'Не удалось распознать формат архива'; explanation = 'Выберите оригинальный .rpa из папки игры. Изменённые или зашифрованные архивы могут не поддерживаться.'; @@ -110,5 +142,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..1e3509b 100644 --- a/static/js/tools-worker.js +++ b/static/js/tools-worker.js @@ -1,6 +1,8 @@ /* 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 packSyntaxReady = false; let inputIndices = []; let browseMounted = false; let commandQueue = Promise.resolve(); @@ -13,13 +15,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 +60,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; @@ -226,9 +275,53 @@ gc.collect() } } +async function packPreview(data) { + let pyodide; + try { + const buffer = await data.file.arrayBuffer(); + packRuntimePromise ||= initializePacker(data.config).catch(error => { packRuntimePromise = null; throw error; }); + pyodide = await packRuntimePromise; + if (!packSyntaxReady) { + const [syntax, lexer] = await Promise.all([ + checkedFetch(data.config.syntax).then(response => response.arrayBuffer()), + checkedFetch(data.config.lexer).then(response => response.text()), + ]); + pyodide.FS.writeFile('/tools/syntax.zip', new Uint8Array(syntax)); + pyodide.FS.writeFile('/tools/renpy_lexer.py', lexer, { encoding: 'utf8' }); + await pyodide.runPythonAsync("import zipfile\nwith zipfile.ZipFile('/tools/syntax.zip') as bundled:\n bundled.extractall('/tools')"); + packSyntaxReady = true; + } + pyodide.FS.writeFile('/tools/pack-preview-input', new Uint8Array(buffer)); + pyodide.globals.set('pack_preview_name', data.name); + const html = await pyodide.runPythonAsync(` +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_for_filename, TextLexer +from pygments.util import ClassNotFound +from renpy_lexer import RenPyLexer +with open('/tools/pack-preview-input', 'rb') as preview_source: + preview_text = preview_source.read().decode('utf-8', errors='replace') +try: + preview_lexer = RenPyLexer() if pack_preview_name.lower().endswith(('.rpy', '.rpym')) else get_lexer_for_filename(pack_preview_name) +except ClassNotFound: + preview_lexer = TextLexer() +highlight(preview_text, preview_lexer, HtmlFormatter(nowrap=True)) +`); + self.postMessage({ type: 'source-file', name: data.name, buffer, html, warnings: [], requestId: data.requestId }, [buffer]); + } catch (error) { + self.postMessage({ type: 'source-error', operation: 'pack-preview', error: String(error.message || error), requestId: data.requestId }); + } finally { + if (pyodide) { + try { pyodide.FS.unlink('/tools/pack-preview-input'); } catch {} + await pyodide.runPythonAsync("for key in ('pack_preview_name', 'preview_text', 'preview_lexer', 'preview_source'):\n globals().pop(key, None)"); + } + } +} + 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', 'pack-preview', '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) + : data.type === 'pack-preview' ? packPreview(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..2a001c3 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'); @@ -12,15 +13,26 @@ 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'); 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 = browserSearch; + 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; @@ -30,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(); @@ -50,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 = ''; @@ -73,7 +101,7 @@ selectedEntry = null; exportReady = false; clearOnDownload = false; - browseFolder = ''; + if (mode !== 'pack') browseFolder = ''; browser.hidden = true; browseActions.hidden = true; warningsPanel.hidden = true; @@ -82,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 = ''; @@ -109,16 +139,34 @@ 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; + 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; 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; + 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); + 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 +174,29 @@ const render = () => { list.replaceChildren(); rows.clear(); - const visible = selected(); - queue.hidden = !visible.length; - list.hidden = false; - get('count').textContent = `${countLabel(visible.length, 'файл', 'файла', 'файлов')} / ${size(visible.reduce((total, entry) => total + entry.file.size, 0))}`; + const allFiles = selected(); + queue.hidden = !allFiles.length; + list.hidden = mode === 'pack'; + get('count').textContent = `${countLabel(allFiles.length, 'файл', 'файла', 'файлов')} / ${size(allFiles.reduce((total, entry) => total + entry.file.size, 0))}`; + get('pack-preview-note').hidden = mode !== 'pack' || pathPreviewValid; + if (mode === 'pack') { updateControls(); return; } + const visible = allFiles.slice(0, 200); 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 +207,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 +218,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 +259,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 +267,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 +317,15 @@ resetWorker(); retryAvailable = false; progress.hidden = true; + if (mode === 'pack') { + validPackPaths.clear(); + queueSearch.value = ''; + queuePage = 0; + refreshPackFolders(); + } say('Добавленные файлы удалены.'); render(); + addButton.focus(); }); const resetWorker = () => { @@ -268,12 +346,126 @@ const showOutputs = () => { resultPanel.hidden = false; 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'); + 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 }); + const validatePack = () => { + let errorMessage = ''; + let nameError = '', prefixError = '', pathError = ''; + packName.removeAttribute('aria-invalid'); + packPrefix.removeAttribute('aria-invalid'); + try { + 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) { + nameError = error.message; + get('pack-filename').textContent = 'Укажите имя архива'; + get('pack-install-example').hidden = true; + 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'); + 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 = 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 = () => { + 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 = () => { + app.dataset.mode = mode; + get('pack-clear-label').hidden = mode !== 'pack'; + get('pack-notice').hidden = mode !== 'pack'; + get('queue-tools').hidden = mode !== 'pack'; + get('mode-pack').checked = mode === 'pack'; + get('mode-unpack').checked = 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(queuePrevious, 'click', () => { queuePage--; renderBrowse(); }); + on(queueNext, 'click', () => { queuePage++; renderBrowse(); }); + 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; + browseFolder = ''; + browserSearch.value = ''; + queuePage = 0; + 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; @@ -285,6 +477,12 @@ if (button.getAttribute('data-entry-id') === entry.id) button.setAttribute('aria-current', 'true'); else button.removeAttribute('aria-current'); } + if (mode === 'pack' && (!/\.(rpy|rpym|txt|md|json|yaml|yml|xml|html|css|js|py|csv|ini|log|sh|bat)$/i.test(entry.path) + || entry.size > 2 * 1024 * 1024)) { + showPreview({ name: entry.path, file: catalogFiles[entry.source].file, buffer: new ArrayBuffer(0), warnings: [] }); + updateControls(); + return; + } const cached = previewCache.get(cacheKey); if (cached) { // Touch the entry so the least recently used preview is evicted first. @@ -313,8 +511,13 @@ updateControls(); try { ensureWorker(); - if (!catalogSubmitted) submitCatalog(); - worker.postMessage({ type: 'source-read', id: entry.id, options: previewOptions, requestId: ++browseRequest }); + if (mode === 'pack') { + worker.postMessage({ type: 'pack-preview', name: entry.path, file: catalogFiles[entry.source].file, + config, requestId: ++browseRequest }); + } else { + if (!catalogSubmitted) submitCatalog(); + worker.postMessage({ type: 'source-read', id: entry.id, options: previewOptions, requestId: ++browseRequest }); + } } catch (error) { fail(error.message); } }; const renderBrowse = () => { @@ -327,6 +530,7 @@ button.disabled = folder === browseFolder && !browserSearch.value; button.addEventListener('click', () => { browseFolder = folder; + queuePage = 0; browserSearch.value = ''; renderBrowse(); }); @@ -343,7 +547,8 @@ const entries = []; for (const entry of browseEntries) { if (query) { - if (entry.path.toLocaleLowerCase().includes(query)) entries.push(entry); + if (entry.path.toLocaleLowerCase().includes(query) + || mode === 'pack' && entry.originalPath.toLocaleLowerCase().includes(query)) entries.push(entry); } else if (entry.path.startsWith(browseFolder)) { const rest = entry.path.slice(browseFolder.length); const slash = rest.indexOf('/'); @@ -370,30 +575,58 @@ item.append(button, meta); browserList.appendChild(item); }; - for (const folder of [...folders].sort((a, b) => a.localeCompare(b, 'ru'))) { - row(folder + '/', 'папка', () => { browseFolder += folder + '/'; renderBrowse(); }, 'folder'); + const folderNames = [...folders].sort((a, b) => a.localeCompare(b, 'ru')); + if (mode === 'pack') entries.sort((a, b) => a.path.localeCompare(b.path, 'ru')); + queueMatches = folderNames.length + entries.length; + const pageSize = mode === 'pack' ? queuePageSize : 500; + queuePage = Math.min(queuePage, Math.max(0, Math.ceil(queueMatches / pageSize) - 1)); + const offset = mode === 'pack' ? queuePage * pageSize : 0; + 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 entries.slice(0, 500)) { + for (const entry of visibleEntries) { const kind = /\.(rpyc|rpymc|pyc|rpy|rpym|py|js|json|css|txt)$/i.test(entry.path) ? 'code' : /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(entry.path) ? 'image' : /\.(ogg|mp3|wav|opus|flac|mp4|webm)$/i.test(entry.path) ? 'media' : 'file'; row(query ? entry.path : entry.path.slice(browseFolder.length), size(entry.size), () => readBrowse(entry), kind, selectedEntry?.id === entry.id, entry.id); - if (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', () => { - files.delete(entry.path); - clearResult(); - render(); - loadCatalog(); - }); - browserList.lastElementChild.appendChild(remove); + if (mode === 'pack' || catalogFiles[entry.source]?.path === entry.path && !isArchive(catalogFiles[entry.source]) && files.has(entry.path)) { + addRemove([mode === 'pack' ? entry.originalPath : entry.path], 'Убрать ' + entry.path, 'Убрать файл'); } } if (!folders.size && !entries.length) { @@ -403,7 +636,12 @@ browserList.appendChild(empty); } browserStatus.textContent = countLabel(folders.size + entries.length, 'элемент', 'элемента', 'элементов') - + (entries.length > 500 ? ' (показаны первые 500; уточните поиск)' : ''); + + (mode !== 'pack' && entries.length > 500 ? ' (показаны первые 500; уточните поиск)' : ''); + if (mode === 'pack') { + get('queue-pages').hidden = queueMatches <= pageSize; + get('queue-range').textContent = queueMatches ? `${offset + 1}–${Math.min(offset + pageSize, queueMatches)} из ${queueMatches}` : ''; + updateControls(); + } }; const renderWarnings = (panel, summary, list, count, messages) => { panel.hidden = !count; @@ -469,7 +707,7 @@ const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml', ogg: 'audio/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', opus: 'audio/ogg', flac: 'audio/flac', mp4: 'video/mp4', webm: 'video/webm', ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2' }[extension] || 'application/octet-stream'; - previewUrl = URL.createObjectURL(new Blob([data.buffer], { type: mime })); + previewUrl = URL.createObjectURL(data.file || new Blob([data.buffer], { type: mime })); browserPreview.replaceChildren(); const title = document.createElement('h4'); title.textContent = path; @@ -519,7 +757,7 @@ specimen.style.fontFamily = 'tools-specimen'; } }).catch(() => { if (previewFont === font) browserStatus.textContent = 'Не удалось открыть шрифт.'; }); - } else if (/\.(rpy|rpym|txt|md|json|yaml|yml|xml|html|css|js|py|csv|ini|log|sh|bat)$/i.test(path) && data.buffer.byteLength > 2 * 1024 * 1024) { + } else if (/\.(rpy|rpym|txt|md|json|yaml|yml|xml|html|css|js|py|csv|ini|log|sh|bat)$/i.test(path) && (data.file?.size || data.buffer.byteLength) > 2 * 1024 * 1024) { const note = document.createElement('p'); note.textContent = 'Файл слишком большой для предпросмотра. Его можно скачать целиком.'; browserPreview.appendChild(note); @@ -587,10 +825,12 @@ previewUrl = null; const source = Number(browseArchive.value) || 0; const scripts = browseArchive.value === 'scripts'; - browseEntries = catalogEntries.filter(entry => scripts + browseEntries = mode === 'pack' ? catalogEntries : catalogEntries.filter(entry => scripts ? catalogFiles[entry.source] && !isArchive(catalogFiles[entry.source]) : entry.source === source); - browseFolder = ''; - browserSearch.value = ''; + if (mode !== 'pack') { + browseFolder = ''; + browserSearch.value = ''; + } else if (!browseEntries.some(entry => entry.path.startsWith(browseFolder))) browseFolder = ''; browserList.replaceChildren(); browserPreview.replaceChildren(); const empty = document.createElement('p'); @@ -598,11 +838,11 @@ empty.textContent = 'Файл не выбран'; browserPreview.appendChild(empty); browser.hidden = false; - get('browser-title').textContent = scripts ? 'Сценарии' : catalogFiles[source]?.path || 'Файлы'; + get('browser-title').textContent = mode === 'pack' ? 'Пути внутри архива' : scripts ? 'Скрипты' : catalogFiles[source]?.path || 'Файлы'; renderBrowse(); }; on(browseArchive, 'change', openBrowse); - on(browserSearch, 'input', renderBrowse); + on(browserSearch, 'input', () => { queuePage = 0; renderBrowse(); }); const errorDiagnostic = error => { const block = document.createElement('div'); block.className = 'tools-diagnostic'; @@ -630,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 => { @@ -667,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; @@ -691,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('Просмотр отменён. Выберите файл, чтобы открыть его ещё раз.'); @@ -740,6 +998,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,21 +1014,32 @@ 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'; 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 = []; @@ -780,37 +1054,70 @@ } 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; - exportReady = result.failed === 0; + clearOnDownload = result.failed === 0 && mode !== 'pack'; + exportReady = result.failed === 0 && outputs.length > 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, 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' }); worker.onmessage = handleMessage; - worker.onerror = () => fail('Не удалось запустить инструменты. Обновите страницу или попробуйте другой браузер.'); + worker.onerror = event => { + const location = event.filename ? `\n${event.filename}:${event.lineno || 0}:${event.colno || 0}` : ''; + fail('Не удалось запустить инструменты.' + (event.message ? `\n${event.message}${location}` : ' Обновите страницу или попробуйте другой браузер.')); + }; worker.onmessageerror = () => fail('Браузер не смог передать файлы инструменту. Попробуйте добавить меньше файлов.'); }; const submitCatalog = () => { @@ -819,6 +1126,20 @@ }; const loadCatalog = () => { if (!supported || busy || enumerating || !selected().length) return; + if (mode === 'pack') { + refreshPackFolders(); + validatePack(); + resultPanel.hidden = false; + downloadAll.hidden = false; + catalogFiles = selected(); + catalogEntries = catalogFiles.map((entry, source) => ({ id: entry.path, + originalPath: entry.path, path: validPackPaths.get(entry.path) || entry.path, + size: entry.file.size, source })); + openBrowse(); + showOutputs(); + updateControls(); + return; + } catalogFiles = selected(); retryAvailable = false; const archives = catalogFiles.map((entry, source) => ({ entry, source })).filter(({ entry }) => isArchive(entry)); @@ -830,7 +1151,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) { @@ -844,7 +1165,7 @@ get('archive-label').hidden = browseActions.hidden; resultPanel.hidden = false; downloadAll.hidden = false; - downloadAll.innerHTML = 'Скачать всё в ZIP'; + downloadAll.innerHTML = downloadButton; openBrowse(); if (!archives.length) { updateControls(); @@ -862,12 +1183,22 @@ }; 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(); + 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; @@ -878,10 +1209,12 @@ progressBar.removeAttribute('value'); current.textContent = ''; say('Подготовка к обработке…'); + openExportDialog(); 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); @@ -889,10 +1222,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)) { @@ -942,7 +1279,10 @@ controller.abort(); resetWorker(); clearResult(); - files.clear(); + modeFiles.pack.clear(); + modeFiles.unpack.clear(); + validPackPaths.clear(); + activeFiles = []; window.__esdocToolsCleanup = null; }; if (!supported) { @@ -952,6 +1292,6 @@ return; } controls.hidden = false; - get('drop-help').textContent = '.rpa, .rpyc, .rpymc, .pyc'; + applyMode(); render(); })(); 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/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/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 1e2860a..1633e4d 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -14,14 +14,19 @@

Инструменты

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

-

@@ -67,13 +107,6 @@

Файлы Ren'Py

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

Обработка файлов

+ +
+ +

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

Предпросмотр обрабатывает только выбранный файл. Декомпилированные скрипты сохраняются в 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/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/characters_browser.cjs b/tests/characters_browser.cjs new file mode 100644 index 0000000..65eeb09 --- /dev/null +++ b/tests/characters_browser.cjs @@ -0,0 +1,82 @@ +/* Run against a local ES Doc server. Set NODE_PATH to 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}}); + const errors = []; page.on('pageerror',error => errors.push(error.message)); + try { + await page.goto(target); await page.locator('#characters-app').waitFor({state:'visible'}); + const code = () => page.locator('#character-code').inputValue(); + assert.equal(await page.locator('.page-title').textContent(),'Создатель простых персонажей'); + assert.equal(await page.locator('#character-name-mode,#character-all-settings,#character-value-who_size').count(),0); + await page.locator('#character-name').fill(''); assert.match(await code(),/Character\(\s*None/); + assert(await page.locator('#character-namebox').isHidden()); + await page.locator('#character-name').fill('player_name'); + await page.locator('#character-dynamic').check(); assert.match(await code(),/dynamic=True/); + assert(await page.locator('#character-dynamic-warning').isVisible()); + const name = await page.locator('#character-preview-who').textContent(); + await page.waitForTimeout(200); assert.notEqual(await page.locator('#character-preview-who').textContent(),name); + await page.locator('#character-color-who_color').fill('#123456'); + await page.locator('#character-value-what_underline').selectOption('True'); + await page.locator('#character-value-what_strikethrough').selectOption('True'); + assert.equal(await page.locator('#character-preview-what').evaluate(el => el.style.textDecoration),'underline line-through'); + await page.locator('#character-kind').selectOption('un'); assert.match(await code(),/kind=un/); + assert.match(await code(),/who_color="#123456"/); + await page.locator('#character-kind').selectOption('custom'); + await page.locator('#character-custom-kind').fill('my_character'); + assert.match(await code(),/kind=my_character/); assert.match(await code(),/who_color="#123456"/); + await page.locator('#character-value-what_suffix').fill('!'); assert.match(await code(),/what_suffix="!"/); + await page.locator('#character-value-who_font').fill('fonts/custom.ttf'); + assert.match(await code(),/who_font="fonts\/custom.ttf"/); + assert.match(await page.locator('#character-help-who_font').textContent(),/невозможно предсмотреть/); + await page.locator('#character-variable').fill('bad name'); assert(await page.locator('#character-copy').isDisabled()); + await page.locator('#character-fix-error').click(); assert.equal(await page.evaluate(() => document.activeElement.id),'character-variable'); + await page.locator('#character-variable').fill('e'); + const saved = await code(); await page.locator('#character-reset').click(); await page.locator('#character-undo').click(); assert.equal(await code(),saved); + await page.reload(); await page.locator('#characters-app').waitFor({state:'visible'}); assert.equal(await code(),saved); + const draft = await page.evaluate(() => localStorage.getItem('es-doc-character-draft-v1')); + await page.locator('#character-reset').click(); + await page.locator('#character-project-file').setInputFiles({name:'character.json',mimeType:'application/json',buffer:Buffer.from(draft)}); + await page.waitForFunction(() => document.querySelector('#character-code').value.includes('kind=my_character')); + assert.equal(await code(),saved); + await page.emulateMedia({reducedMotion:'reduce'}); await page.locator('#character-name').fill('player_name_reduced'); + const reduced = await page.locator('#character-preview-who').textContent(); await page.waitForTimeout(200); assert.equal(await page.locator('#character-preview-who').textContent(),reduced); + await page.locator('#character-dynamic').uncheck(); + const background = await page.locator('#character-dialogue-window').evaluate(el => getComputedStyle(el).backgroundImage); + assert.match(background,/dialogue_box\/day\/dialogue_box.png/); + const artwork = await page.request.get(new URL('/resource/raw/images/gui/dialogue_box/day/dialogue_box.png',target).href); assert.equal(artwork.status(),200); + await page.locator('#character-kind').selectOption(''); + await page.locator('#character-name').fill('Семён'); + await page.locator('#character-value-who_color').fill(''); + await page.locator('#character-value-what_underline').selectOption(''); await page.locator('#character-value-what_strikethrough').selectOption(''); + const geometry = await page.locator('#character-dialogue-window').evaluate(el => { + const box = el.getBoundingClientRect(), who = document.querySelector('#character-preview-who').getBoundingClientRect(), what = document.querySelector('#character-preview-what').getBoundingClientRect(); + return {ratio:box.width/box.height,whoX:(who.left-box.left)/box.width,whoY:(who.top-box.top)/box.height,whatY:(what.top-box.top)/box.height}; + }); + assert(Math.abs(geometry.ratio - 1572/151) < .02,'Original dialogue artwork must keep its aspect ratio'); + assert(Math.abs(geometry.whoX - 20/1572) < .002,'Name keeps the original left inset'); + assert(Math.abs(geometry.whoY - 15/151) < .01,'Name keeps the original vertical inset'); + assert(Math.abs(geometry.whatY - 48/151) < .01,'Dialogue starts below the original name tab'); + await page.locator('#character-preview-heading').scrollIntoViewIfNeeded(); + await page.screenshot({path:'temp/characters-desktop.png'}); + await page.setViewportSize({width:390,height:844}); + await page.locator('#character-preview-heading').scrollIntoViewIfNeeded(); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({path:'temp/characters-mobile.png'}); + await page.locator('#character-zoom').click(); + assert.equal(await page.locator('#character-zoom').getAttribute('aria-pressed'),'true'); + const zoom = await page.locator('#character-stage').evaluate(el => {const box=el.getBoundingClientRect(); return {width:box.width,ratio:box.width/box.height};}); + assert(zoom.width >= 960); assert(Math.abs(zoom.ratio-1572/151) < .02); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),'Zoom scroll stays inside the preview'); + await page.locator('#character-name').fill('Имя'.repeat(100)); await page.locator('#character-sample').fill('Длинная реплика '.repeat(100)); + const longRatio = await page.locator('#character-stage').evaluate(el => el.clientWidth/el.clientHeight); + assert(Math.abs(longRatio - 1572/151) < .1,'Long text must not stretch the artwork'); + await page.locator('#character-zoom').click(); + + await page.locator('.page-back').click(); await page.waitForURL('**/tools'); await page.goBack(); await page.locator('#characters-app').waitFor({state:'visible'}); + assert.deepEqual(errors,[]); console.log('Character browser checks passed (desktop/mobile, dynamic/reduced motion, inheritance, export, draft and undo).'); + } 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..ae36f3b --- /dev/null +++ b/tests/characters_core.test.mjs @@ -0,0 +1,99 @@ +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('simple creator exports empty names as narration and restricts restored properties', () => { + const s = state(); s.name = ''; + assert.match(core().compile(s).code, /Character\(None\)/); + s.values = {who_color:{mode:'text',value:'#abc'}, what_size:{mode:'number',value:'42'}}; + const restored = core().simpleState(s); + assert.equal(restored.values.who_color.value, '#abc'); + assert.equal(restored.values.what_size, undefined); + assert.equal(restored.nameMode, 'text'); + assert.equal(restored.values.dynamic.value, 'False', 'Normal names must not inherit DynamicCharacter evaluation'); +}); + +test('simple properties cover both name and dialogue without engine settings', () => { + const keys = core().fields.map(field => field.key); + for (const prefix of ['who','what']) for (const property of ['color','prefix','suffix','font','bold','italic','strikethrough','underline']) { + assert(keys.includes(prefix + '_' + property)); + } + assert.equal(keys.length, 18); +}); + +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('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/colors_ui.test.mjs b/tests/colors_ui.test.mjs index d3f589f..e314360 100644 --- a/tests/colors_ui.test.mjs +++ b/tests/colors_ui.test.mjs @@ -45,10 +45,10 @@ function page(options = {}) { const copied = []; const stored = new Map(); const timers = new Map(); - const window = {...options, navigator: {clipboard: {writeText: async value => copied.push(value)}}, + const window = {...options, navigator: {clipboard: {writeText: async value => copied.push(value)}, ...options.navigator}, localStorage: {getItem: key => stored.get(key), setItem: (key, value) => stored.set(key, value)}}; let timerId = 0; - const context = vm.createContext({ document, window, AbortController, Event, setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id) }); + const context = vm.createContext({ document, window, AbortController, DOMException, Event, setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id) }); for (const file of ['colors-named.js', 'colors-core.js', 'colors.js']) vm.runInContext(fs.readFileSync(new URL('../static/js/' + file, import.meta.url), 'utf8'), context); const input = (id, value) => { const element = get('colors-' + id); element.focus(); element.value = value; element.emit('input'); }; return { get: id => get('colors-' + id), input, copied, window, stored, document, runTimers: () => { for (const callback of timers.values()) callback(); timers.clear(); } }; @@ -105,6 +105,105 @@ 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('pipette warning appears only in Firefox', () => { + for (const [userAgent, visible] of [ + ['Mozilla/5.0 Gecko/20100101 Firefox/143.0', true], + ['Mozilla/5.0 FxiOS/143.0', true], + ['Mozilla/5.0 Chrome/143.0 Safari/537.36', false], + ['', false] + ]) { + const ui = page({navigator: {userAgent}}); + assert.equal(ui.get('eyedropper-warning').hidden, !visible); + assert.equal(ui.get('eyedropper').title, undefined); + } +}); + +test('Firefox tooltip supports hover, keyboard focus, Escape and touch', () => { + const ui = page({navigator: {userAgent: 'Firefox/143.0'}}); + const warning = ui.get('eyedropper-warning'); + const trigger = ui.get('eyedropper-warning-trigger'); + const tooltip = ui.get('eyedropper-tooltip'); + warning.emit('pointerenter', {pointerType: 'mouse'}); + assert.equal(tooltip.hidden, true); + ui.runTimers(); + assert.equal(tooltip.hidden, false); + warning.emit('pointerleave'); + assert.equal(tooltip.hidden, true); + trigger.emit('focus'); + assert.equal(tooltip.hidden, false); + ui.document.emit('keydown', {key: 'Escape'}); + assert.equal(tooltip.hidden, true); + trigger.emit('blur'); + warning.emit('pointerenter', {pointerType: 'mouse'}); + warning.emit('pointerleave'); + ui.runTimers(); + assert.equal(tooltip.hidden, true); + trigger.click(); + assert.equal(tooltip.hidden, false); + ui.window.__esdocColorsCleanup(); + ui.runTimers(); + assert.equal(tooltip.hidden, true); +}); + +test('screen capture selects external window colors from a frozen preview using its own coordinates', async () => { + let stopped = 0, drawn = 0, sampled; + const ui = page({isSecureContext: true, navigator: {mediaDevices: {getDisplayMedia: async options => { + assert.equal(options.preferCurrentTab, undefined); + return {getTracks: () => [{stop: () => stopped++}]}; + }}}}); + ui.document.createElement = name => { + assert.equal(name, 'video'); + return {videoWidth: 1600, videoHeight: 900, play: async () => {}}; + }; + const canvas = ui.get('screen-canvas'); + canvas.getBoundingClientRect = () => ({left: 100, top: 20, width: 800, height: 450}); + canvas.getContext = () => ({drawImage: () => drawn++, getImageData: (x, y) => { + sampled = [x, y]; return {data: [18, 52, 86, 255]}; + }}); + ui.get('eyedropper').click(); + assert.equal(ui.get('status').textContent, 'Выберите экран или окно для снимка.'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(canvas.hidden, false); + assert.equal(stopped, 1); + assert.equal(drawn, 1); + ui.get('eyedropper-overlay').emit('pointerdown', {target: canvas, button: 0, clientX: 500, clientY: 245, preventDefault() {}}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(sampled, [800, 450]); + assert.equal(drawn, 1); + assert.equal(ui.get('output-hex').value, '#123456'); + assert.equal(canvas.hidden, true); + assert.equal(ui.get('eyedropper-overlay').hidden, true); +}); + +test('screen capture cancellation during permission prompt stops a late stream', async () => { + let resolveCapture, stopped = 0; + const ui = page({isSecureContext: true, navigator: {mediaDevices: { + getDisplayMedia: () => new Promise(resolve => { resolveCapture = resolve; }) + }}}); + const original = ui.get('output-hex').value; + ui.get('eyedropper').click(); + ui.document.emit('keydown', {key: 'Escape', preventDefault() {}}); + resolveCapture({getTracks: () => [{stop: () => stopped++}]}); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(stopped, 1); + assert.equal(ui.get('screen-canvas').hidden, true); + assert.equal(ui.get('eyedropper-overlay').hidden, true); + assert.equal(ui.get('eyedropper').disabled, false); + assert.equal(ui.get('output-hex').value, original); +}); + test('right click or Escape cancels the eyedropper without applying a late result', async () => { let resolvePick, signal; const ui = page({isSecureContext: true, EyeDropper: class { 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/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 new file mode 100644 index 0000000..b8a97f5 --- /dev/null +++ b/tests/navigation.test.mjs @@ -0,0 +1,221 @@ +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); }, + replaceState(state, title, 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, window, + replace(url) { current = new URL(url, current); }, + 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('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); + 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']); +}); + +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'); +}); 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); +}); diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs new file mode 100644 index 0000000..3e6536b --- /dev/null +++ b/tests/rpa_browser.cjs @@ -0,0 +1,274 @@ +/* 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-tree-browser/game'); +const target = process.env.RPA_TEST_URL || 'http://127.0.0.1:8012/tools/unpack?mode=pack'; +const resources = { + '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); + 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-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-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 => + 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(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-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()); + 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(); + 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-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-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-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-export-start').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]); + 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'); + + // 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); + 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-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:', + ' 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, []); + + // 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(); + 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-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-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) => ({ + name: `item-${String(i).padStart(3, '0')}.bin`, mimeType: 'application/octet-stream', buffer: Buffer.from([i % 256]), + }))); + 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-browser-list button[data-entry-id]').count(), 51); + assert.match(await page.locator('#tools-queue-range').textContent(), /201–251 из 251/); + 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-browser-search').fill(''); + await page.locator('#tools-browser-list .tools-remove-source').first().focus(); + await page.keyboard.press('Enter'); + 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-browser-list .tools-remove-source').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, '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']]) { + 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-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 => { + 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/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' }, diff --git a/tests/test_image_cache.py b/tests/test_image_cache.py new file mode 100644 index 0000000..bb695ce --- /dev/null +++ b/tests/test_image_cache.py @@ -0,0 +1,90 @@ +import asyncio +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from PIL import Image + +from app.routes.res import _ensure_tinted +from app.utils.config import CONFIG +from app.utils.lifespan.sprites_cache import compose_sprite, is_composed, sprites_path +from app.utils.lifespan.tint_cache import compose_tint, is_tinted + + +class ImageCacheTests(unittest.TestCase): + def setUp(self): + directory = TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.game = self.root / 'game' + self.game.mkdir() + declarations = self.game / 'sprites.rpy' + declarations.write_text('', encoding='utf-8') + os.utime(declarations, (100, 100)) + for change in (patch.object(CONFIG, 'res_path', self.game), + patch.object(CONFIG, 'cache_path', self.root / 'cache'), + patch.object(CONFIG, 'sprite_layers', {'fixture': ['layer.png']}), + patch.object(CONFIG, 'resources', {})): + change.start() + self.addCleanup(change.stop) + sprites_path().mkdir(parents=True) + + def image(self, name, color, timestamp=100): + path = self.game / name + Image.new('RGBA', (2, 2), color).save(path) + os.utime(path, (timestamp, timestamp)) + return path + + def test_recomposes_sprite_after_layer_replacement(self): + self.image('layer.png', 'red') + output = compose_sprite('fixture') + os.utime(output, (200, 200)) + self.assertTrue(is_composed('fixture')) + self.image('layer.png', 'lime', timestamp=300) + self.assertFalse(is_composed('fixture')) + compose_sprite('fixture') + with Image.open(output) as image: + red, green, _ = image.convert('RGB').getpixel((0, 0)) + self.assertGreater(green, 200) + self.assertLess(red, 20) + + def test_tint_refreshes_when_factors_change_without_source_edits(self): + source = self.image('source.png', 'white') + output = compose_tint('bg', 'fixture', source, (1, 0, 0)) + os.utime(output, (200, 200)) + CONFIG.resources = {'original': {'bg': [{'name': 'fixture', 'file': 'source.png', 'tint': (0, 1, 0)}]}} + asyncio.run(_ensure_tinted('bg', 'fixture')) + with Image.open(output) as image: + red, green, _ = image.convert('RGB').getpixel((0, 0)) + self.assertGreater(green, 200) + self.assertLess(red, 20) + + def test_tint_refreshes_when_source_identity_changes_to_an_older_file(self): + first = self.image('first.png', 'red') + self.image('second.png', 'lime', timestamp=50) + output = compose_tint('bg', 'fixture', first, (1, 1, 1)) + os.utime(output, (200, 200)) + CONFIG.resources = {'original': {'bg': [{'name': 'fixture', 'file': 'second.png', 'tint': (1, 1, 1)}]}} + asyncio.run(_ensure_tinted('bg', 'fixture')) + with Image.open(output) as image: + red, green, _ = image.convert('RGB').getpixel((0, 0)) + self.assertGreater(green, 200) + self.assertLess(red, 20) + + def test_tint_cache_reuses_matching_inputs_and_rebuilds_legacy_metadata(self): + source = self.image('source.png', 'white') + tint = (1, 0, 0) + output = compose_tint('bg', 'fixture', source, tint) + self.assertTrue(is_tinted('bg', 'fixture', source, tint)) + output.with_suffix('.json').unlink() + self.assertFalse(is_tinted('bg', 'fixture', source, tint)) + compose_tint('bg', 'fixture', source, tint) + self.assertTrue(is_tinted('bg', 'fixture', source, tint)) + + def test_cached_sprite_is_invalid_when_a_layer_is_removed(self): + layer = self.image('layer.png', 'red') + compose_sprite('fixture') + layer.unlink() + self.assertFalse(is_composed('fixture')) diff --git a/tests/test_md_containers.py b/tests/test_md_containers.py new file mode 100644 index 0000000..2a9bd6d --- /dev/null +++ b/tests/test_md_containers.py @@ -0,0 +1,43 @@ +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'): + with self.subTest(fence=fence, marker=marker): + source = f':::details Example\n{fence}text\n{marker}\n{fence}\n\nINSIDE\n:::\n\nOUTSIDE\n' + html = render(source)[2] + self.assertIn('
]*>', '', html))) + self.assertLess(html.index('INSIDE'), html.index('
')) + self.assertGreater(html.index('OUTSIDE'), html.index('
')) + + 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) 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') diff --git a/tests/test_resource_refresh.py b/tests/test_resource_refresh.py new file mode 100644 index 0000000..177aa6a --- /dev/null +++ b/tests/test_resource_refresh.py @@ -0,0 +1,43 @@ +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from app.utils.config import CONFIG +from app.utils.lifespan.refresh import _watchers +from app.utils.lifespan.resources_cache import parse_resources + + +class OriginalResourceRefreshTests(unittest.TestCase): + def test_original_file_changes_reparse_resource_listing(self): + with TemporaryDirectory() as directory: + assets = Path(directory) + game = assets / 'game' + image = game / 'images' / 'bg' / 'new.jpg' + image.parent.mkdir(parents=True) + (game / 'resources.rpy').write_text('', encoding='utf-8') + with (patch.object(CONFIG, 'docs_path', assets / 'docs'), + patch.object(CONFIG, 'res_path', game), + patch.object(CONFIG, 'sprite_layers', {}), + patch.object(CONFIG, 'resources', {})): + parse_resources() + image.write_bytes(b'image') + for name, matches, refresh in _watchers(): + if name == 'resources' and matches(image.resolve()): + refresh() + files = [item['file'] for item in CONFIG.resources['original']['bg']] + self.assertIn('images/bg/new.jpg', files) + image.unlink() + for name, matches, refresh in _watchers(): + if name == 'resources' and matches(image.resolve()): + refresh() + self.assertNotIn('images/bg/new.jpg', [item['file'] for item in CONFIG.resources['original']['bg']]) + + def test_audio_and_translation_changes_invalidate_resource_metadata(self): + assets = Path(__file__).resolve().parents[1] / 'temp' / 'refresh-fixture' + game = assets / 'game' + with patch.object(CONFIG, 'docs_path', assets / 'docs'), patch.object(CONFIG, 'res_path', game): + matches = next(predicate for name, predicate, _ in _watchers() if name == 'resources') + for relative in ('audio/music/new.ogg', 'tl/None/translation.rpy', 'zhenya/images/bg/new.jpg'): + with self.subTest(relative=relative): + self.assertTrue(matches(game / relative)) diff --git a/tests/test_search_discovery.py b/tests/test_search_discovery.py index 2cb35b2..6da9915 100644 --- a/tests/test_search_discovery.py +++ b/tests/test_search_discovery.py @@ -9,6 +9,26 @@ class SearchDiscoveryTests(unittest.IsolatedAsyncioTestCase): + async def test_normalizes_trailing_slash_and_surrounding_whitespace(self): + with patch.object(CONFIG, 'site_url', ' https://docs.example/ '): + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://localhost') as client: + robots = await client.get('/robots.txt') + sitemap = await client.get('/sitemap.xml') + self.assertIn('Sitemap: https://docs.example/sitemap.xml\n', robots.text) + self.assertIn('https://docs.example/docs/', sitemap.text) + self.assertNotIn('https://docs.example//', sitemap.text) + + async def test_missing_or_invalid_origin_does_not_publish_broken_urls(self): + for origin in (None, '', 'not-a-url', 'https://docs.example/path', 'https://docs.example?query=1'): + with self.subTest(origin=origin), patch.object(CONFIG, 'site_url', origin): + async with AsyncClient(transport=ASGITransport(app=app, raise_app_exceptions=False), base_url='http://untrusted-host') as client: + robots = await client.get('/robots.txt') + sitemap = await client.get('/sitemap.xml') + self.assertEqual(robots.status_code, 200) + self.assertNotIn('Sitemap:', robots.text) + self.assertEqual(sitemap.status_code, 404) + self.assertNotIn('untrusted-host', robots.text) + async def test_robots_points_to_sitemap_on_configured_origin(self): with patch.object(CONFIG, 'site_url', 'https://docs.example'): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://localhost') as client: diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 0000000..5b8acd4 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,23 @@ +import unittest + +from httpx import ASGITransport, AsyncClient + +from app.app import app + + +class StaticRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_rejects_windows_traversal_and_absolute_paths(self): + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: + for folder in ('css', 'js', 'fonts'): + for name in ('..%5C..%5CREADME.md', 'D:%5CGITHUB%5Ces-doc%5CREADME.md', '%00'): + with self.subTest(folder=folder, name=name): + response = await client.get(f'/static/{folder}/{name}') + self.assertEqual(response.status_code, 404) + + async def test_serves_normal_assets_and_returns_404_for_missing_fonts(self): + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: + for path in ('css/main.css', 'js/navigation.js', 'fonts/inter-variable-latin.woff2'): + response = await client.get('/static/' + path) + self.assertEqual(response.status_code, 200, path) + response = await client.get('/static/fonts/missing.woff2') + self.assertEqual(response.status_code, 404) diff --git a/tests/test_tools.py b/tests/test_tools.py index ea3d651..f5c3883 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) @@ -44,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-kind', + '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) @@ -68,7 +87,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_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' 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_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); 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..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,11 +17,24 @@ 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; } 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') || (selector === '.tools-remove-source' && child.className?.includes('tools-remove-source'))) 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 +106,397 @@ 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'; + 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('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'); + 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('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('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'; + 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'; + 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('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('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('browser-list').querySelectorAll('.tools-remove-source')[0]; + remove.focus(); remove.click(); + assert.equal(ui.document.activeElement, ui.get('add')); + 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('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')); +}); + +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']); + 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'); + assert.equal(path(), 'mods/demo/bg.png'); + assert.equal(ui.get('pack-preview-note').hidden, false); + assert.match(ui.get('pack-prefix-error').textContent, /относительный путь.*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('packing errors belong to their fields and clear independently', () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = ''; + ui.get('pack-prefix').value = '../bad'; + ui.get('pack-prefix').emit('input'); + assert.match(ui.get('pack-name-error').textContent, /имя архива/); + assert.match(ui.get('pack-prefix-error').textContent, /относительный путь/); + ui.get('pack-name').value = 'demo'; + ui.get('pack-name').emit('input'); + assert.equal(ui.get('pack-name-error').hidden, true); + assert.equal(ui.get('pack-prefix-error').hidden, false); + assert.equal(ui.get('pack-filename').textContent, 'demo.rpa'); + ui.get('pack-prefix').value = 'mods/demo'; + ui.get('pack-prefix').emit('input'); + assert.equal(ui.get('pack-prefix-error').hidden, true); +}); + +test('archive connection example tracks the download name and hides invalid code', () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'chapter.rpa'; + ui.get('pack-name').emit('input'); + assert.equal(ui.get('pack-install-archive').textContent, 'game/my_mod/chapter.rpa'); + assert.equal(ui.get('pack-install-string').textContent, '"my_mod/chapter"'); + ui.get('pack-name').value = '../bad'; + ui.get('pack-name').emit('input'); + assert.equal(ui.get('pack-install-example').hidden, true); + ui.get('pack-name').value = 'new'; + ui.get('pack-name').emit('input'); + assert.equal(ui.get('pack-install-example').hidden, false); + assert.equal(ui.get('pack-install-string').textContent, '"my_mod/new"'); + ui.get('mode-unpack').emit('change'); + assert.equal(ui.get('pack-integration').hidden, true); +}); + +test('packing picker menu and path example follow the selected resources', async () => { + const ui = page('?mode=pack'); + ui.get('pack-name').value = 'demo'; + ui.get('add').click(); + assert.equal(ui.get('add-menu').hidden, false); + assert.equal(ui.document.activeElement, ui.get('pick-files')); + ui.get('pick-folder').click(); + assert.equal(ui.get('folder').clicks, 1); + ui.get('add').click(); + ui.get('pick-files').click(); + assert.equal(ui.get('files').clicks, 1); + await ui.add(['bg.png']); + ui.get('pack-prefix').value = 'mods/demo/images'; + ui.get('pack-prefix').emit('input'); + assert.equal(ui.get('pack-example-source').textContent, 'bg.png'); + assert.equal(ui.get('pack-example-target').textContent, 'mods/demo/images/bg.png'); + ui.get('pack-prefix').value = '../bad'; + ui.get('pack-prefix').emit('input'); + assert.equal(ui.get('pack-example-target').textContent, 'Исправьте настройки пути'); + ui.get('clear').click(); + assert.equal(ui.get('pack-example-source').textContent, 'images/bg.png'); +}); + +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 } @@ -262,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); }); @@ -275,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 () => { @@ -348,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 () => {