diff --git a/CLAUDE.md b/CLAUDE.md index a4480f9f..a48fc426 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,10 @@ It shows the results with pugwash's fifo dialogs (`pmtest/fifo.py`) and writes ` - **Translate view:** a translation editor. `/api/strings` lists a language's strings, and `/api/translate` saves one into the language files, in `strings.json` order. It only accepts requests from the page itself: they need the `X-Theme-Server` header. - Renderers are keyed by the theme and the language files together, so a saved translation re-renders. - `translations.py` is the language-file code (`tools/i18n.py` runs its `extract`/`check`). +- **Edit view:** a `theme.json` editor (CodeMirror 5, vendored as `static/codemirror/` with its MIT `LICENSE`, served from a fixed list, `STATIC_FILES`). The page code is `static/editor.js`. + - `editor.py` (`ThemeEditor`) reads and saves `theme.json` (`/api/theme_file`; the old file becomes `theme.json.bak`, and a stale version is refused with 409). It also makes drafts (`/api/theme_draft`: the unsaved text in a copy of the theme made of links, `~draft` in `Themes.drafts`, rendered like any theme but never listed) and copies themes into `--themes` (`/api/theme_copy`). The built-in default theme is never writable. + - `schema.py` (`/api/schema`) reads what the suggestions and checks need from pugwash's code with `ast`/regexes, never importing it: region keys and types from `Region`'s `_verify_*` calls, sections from the default theme and `load_regions`, `{tags}` from `set_data`, and capabilities from the themes. + - Tested in `tests/test_theme_editor.py` and `test_theme_server_http.py`. - **On a device:** Options → Theme Editor (`pugwash/scenes/theme_server.py`) runs `python3 -m theme_server.serve --managed` on a port from the dynamic range (49152-65535, where no CFW service listens): random the first time, then kept in config.json as `theme_server_port` so the address stays the same; if it's taken, the next free one or any (`--try-ports`, `--any-port`), which is kept instead, with as many workers as `MemAvailable` allows. The screen shows the address as text and as a QR code (`"image": "qr:{theme_server.url}"`, the default theme's `theme_server` section, or a plain fallback). `--managed` reports `{"ok": true, "port": N}` on stdout and stops when stdin closes, so B, or pugwash dying, stops it. Its log is `PortMaster/theme_server.txt`. - Tested in `tests/test_theme_server.py`. diff --git a/PortMaster/pylibs/theme_server/editor.py b/PortMaster/pylibs/theme_server/editor.py new file mode 100644 index 00000000..1599c261 --- /dev/null +++ b/PortMaster/pylibs/theme_server/editor.py @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: MIT +# +# The Edit view's files: reading and saving a theme's theme.json, drafts (unsaved text +# rendered from a copy of the theme made of links, so the preview follows the typing +# without touching the theme), and copying a theme to start a new one. +# +# Python 3.7, standard library only. + +import hashlib +import json +import os +import re +import shutil +import threading +from pathlib import Path + + +MAX_THEME_SIZE = 4 * 1024 * 1024 +DRAFT_SUFFIX = "~draft" +NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9 _.()+-]{0,47}$') + + +class EditorError(Exception): + def __init__(self, status, message, **extra): + super().__init__(message) + self.status = status + self.extra = extra + + +def text_version(data): + return hashlib.sha1(data).hexdigest()[:16] + + +def check_text(text): + """None if text is a theme.json PortMaster can load (a JSON object), else {'error', 'line', 'column'}.""" + try: + data = json.loads(text) + except ValueError as error: + return {'error': getattr(error, 'msg', str(error)), 'line': getattr(error, 'lineno', None), 'column': getattr(error, 'colno', None)} + if not isinstance(data, dict): + return {'error': "theme.json has to be a JSON object: { ... }", 'line': 1, 'column': 1} + return None + + +def rename_text(text, name): + """theme.json's text with #info's name set to name, keeping its layout where it can.""" + data = json.loads(text) + info = data.get("#info") + match = re.search(r'"#info"\s*:\s*\{', text) + if isinstance(info, dict) and match: + ## Its "name" (the first one in #info, at #info's own depth). + depth, index = 1, match.end() + in_string = False + while index < len(text) and depth > 0: + char = text[index] + if in_string: + if char == "\\": + index += 1 + elif char == '"': + in_string = False + elif char == '"': + if depth == 1: + found = re.compile(r'"name"\s*:\s*"((?:[^"\\]|\\.)*)"').match(text, index) + if found: + new_text = text[:found.start(1)] + json.dumps(name)[1:-1] + text[found.end(1):] + if json.loads(new_text).get("#info", {}).get("name") == name: + return new_text + in_string = True + elif char in "{[": + depth += 1 + elif char in "}]": + depth -= 1 + index += 1 + + ## No name to change: written out again. + if not isinstance(data.get("#info"), dict): + data["#info"] = {} + data["#info"]["name"] = name + return json.dumps(data, indent=4, ensure_ascii=False) + "\n" + + +class ThemeEditor: + """theme.json files of the server's themes (serve.Themes), and their drafts.""" + def __init__(self, themes, work_dir, default_theme_dir): + self.themes = themes + self.drafts_dir = Path(work_dir) / "drafts" + self.default_theme_dir = Path(default_theme_dir).resolve() + self.lock = threading.Lock() + + def _theme(self, theme_id): + if not isinstance(theme_id, str) or theme_id.endswith(DRAFT_SUFFIX): + raise EditorError(404, "no such theme") + theme_dir = self.themes.get(theme_id) + if theme_dir is None: + raise EditorError(404, f"no theme {theme_id!r}") + return Path(theme_dir) + + def writable(self, theme_dir): + """Only the themes in --themes, never the built-in default theme.""" + theme_dir = Path(theme_dir).resolve() + root = self.themes.themes_dir + if root is None or theme_dir == self.default_theme_dir: + return False + if theme_dir != root and theme_dir.parent != root: + return False + return os.access(str(theme_dir), os.W_OK) and os.access(str(theme_dir / "theme.json"), os.W_OK) + + def copy_folder(self): + """Where a copy goes: --themes, when it's a folder of themes.""" + root = self.themes.themes_dir + if root is None or not root.is_dir() or (root / "theme.json").is_file() or not os.access(str(root), os.W_OK): + return None + return root + + def read(self, theme_id): + theme_dir = self._theme(theme_id) + try: + data = (theme_dir / "theme.json").read_bytes() + except OSError as error: + raise EditorError(404, f"can't read theme.json: {error.strerror}") + return {'theme': theme_id, 'text': data.decode("utf-8", "replace"), 'version': text_version(data), + 'writable': self.writable(theme_dir), 'can_copy': self.copy_folder() is not None, + ## Downloaded from the theme list (it wrote theme.md5): updating it there replaces it. + 'downloaded': (theme_dir / "theme.md5").is_file()} + + def save(self, theme_id, text, version): + """Writes theme.json (the old one becomes theme.json.bak) if it's still `version` on disk.""" + theme_dir = self._theme(theme_id) + if not self.writable(theme_dir): + raise EditorError(403, "this theme can't be changed here: copy it to a new theme first") + data = text.encode("utf-8") + if len(data) > MAX_THEME_SIZE: + raise EditorError(413, "theme.json is too big") + problem = check_text(text) + if problem is not None: + raise EditorError(400, f"not saved, it isn't valid JSON: {problem['error']}", **problem) + + theme_file = theme_dir / "theme.json" + with self.lock: + old = theme_file.read_bytes() + if text_version(old) != version: + ## Changed since the page loaded it (another editor, a copy from the PC). + raise EditorError(409, "theme.json changed on disk since you opened it", + text=old.decode("utf-8", "replace"), version=text_version(old)) + if old != data: + (theme_dir / "theme.json.bak").write_bytes(old) + temp = theme_dir / "theme.json.tmp" + temp.write_bytes(data) + os.replace(str(temp), str(theme_file)) + + return {'ok': True, 'version': text_version(data)} + + def draft(self, theme_id, text): + """The id of a theme that is this one with theme.json's text: for the preview, saving nothing.""" + theme_dir = self._theme(theme_id) + data = text.encode("utf-8") + if len(data) > MAX_THEME_SIZE: + raise EditorError(413, "theme.json is too big") + problem = check_text(text) + if problem is not None: + raise EditorError(400, problem['error'], **problem) + + draft_id = theme_id + DRAFT_SUFFIX + draft_dir = self.drafts_dir / re.sub(r'[^A-Za-z0-9_.-]', "_", theme_id) + with self.lock: + self._mirror(theme_dir, draft_dir) + draft_file = draft_dir / "theme.json" + if not draft_file.is_file() or draft_file.read_bytes() != data: + temp = draft_dir / "theme.json.tmp" + temp.write_bytes(data) + os.replace(str(temp), str(draft_file)) + self.themes.drafts[draft_id] = draft_dir + + return {'ok': True, 'theme': draft_id} + + @staticmethod + def _mirror(theme_dir, draft_dir): + """draft_dir: links to every file of theme_dir but theme.json (copies where links don't work).""" + wanted = set() + for root, dirs, files in os.walk(str(theme_dir)): + dirs[:] = [name for name in dirs if name != "__pycache__"] + for name in files: + relative = os.path.relpath(os.path.join(root, name), str(theme_dir)) + if relative not in ("theme.json", "theme.json.bak", "theme.json.tmp"): + wanted.add(relative) + + draft_dir.mkdir(parents=True, exist_ok=True) + for root, dirs, files in os.walk(str(draft_dir)): + for name in files: + relative = os.path.relpath(os.path.join(root, name), str(draft_dir)) + if relative not in wanted and relative != "theme.json": + os.unlink(os.path.join(root, name)) + + for relative in sorted(wanted): + source, target = theme_dir / relative, draft_dir / relative + if os.path.lexists(str(target)): + continue + target.parent.mkdir(parents=True, exist_ok=True) + try: + os.symlink(str(source.resolve()), str(target)) + except OSError: + shutil.copy2(str(source), str(target)) + + def copy(self, theme_id, name, text=None): + """A new theme in --themes: a copy of this one, called name, with theme.json's text + if it's given (the unsaved changes). Returns its id.""" + theme_dir = self._theme(theme_id) + root = self.copy_folder() + if root is None: + raise EditorError(403, "there's no themes folder to copy it into") + if not isinstance(name, str) or not NAME_RE.match(name.strip()): + raise EditorError(400, "a name of letters, numbers, spaces and . _ - ( ) +, up to 48 long") + name = name.strip() + new_id = re.sub(r'\s+', "_", name) + target = root / new_id + if text is not None: + problem = check_text(text) if isinstance(text, str) and len(text.encode("utf-8")) <= MAX_THEME_SIZE else {'error': "too big"} + if problem is not None: + raise EditorError(400, f"not copied, theme.json isn't valid JSON: {problem['error']}", **problem) + if new_id.endswith(DRAFT_SUFFIX) or self.themes.get(new_id) is not None or os.path.lexists(str(target)): + raise EditorError(409, f"there's a theme called {new_id!r} already") + + with self.lock: + shutil.copytree(str(theme_dir), str(target), + ignore=shutil.ignore_patterns("__pycache__", "theme.json.bak", "theme.json.tmp", "theme.md5", "*.pyc")) + try: + if text is None: + text = (target / "theme.json").read_text(encoding="utf-8") + (target / "theme.json").write_text(rename_text(text, name), encoding="utf-8") + except (OSError, ValueError): + ## A theme.json that doesn't load is copied as it is. + pass + + return {'ok': True, 'theme': new_id} diff --git a/PortMaster/pylibs/theme_server/schema.py b/PortMaster/pylibs/theme_server/schema.py new file mode 100644 index 00000000..f13e7b11 --- /dev/null +++ b/PortMaster/pylibs/theme_server/schema.py @@ -0,0 +1,267 @@ +# SPDX-License-Identifier: MIT +# +# What the theme editor knows about theme.json, for its autocomplete and its checks: read +# from pugwash's own code (no import, so no SDL), so it can't drift from what PortMaster +# accepts. +# - region keys and their types: the `self._verify_('key', ...)` calls in +# sdl/region.py, with the options of `_verify_option` +# - sections: the default theme's, and the `load_regions("section", ...)` of the scenes +# - template tags: every "x.y" key pugwash sets with `set_data` / `text_data[...]` +# - capabilities: the [...] requirements the default theme and the other themes use +# +# Python 3.7, standard library only. + +import ast +import json +import re +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +PYLIBS = HERE.parent +PUGWASH = PYLIBS / "pugwash" +DEFAULT_THEME = PYLIBS / "default_theme" / "theme.json" +RESOURCES = PYLIBS / "resources" + +## Keys a region can have that aren't a _verify_* call. +EXTRA_KEYS = { + 'image': {'type': "image"}, + 'pimage': {'type': "image"}, + 'pointer': {'type': "image"}, + 'music': {'type': "file"}, + 'music-volume': {'type': "int", 'min': 0, 'max': 128}, + 'tag': {'type': "text"}, + 'comment': {'type': "text"}, + } + +## The top level of theme.json: "#" sections, then the screens. +TOP_KEYS = { + '#info': "the theme's name, creator, description and default-scheme", + '#schemes': "color schemes: each has a #pallet and can change #resources and any section", + '#base': "keys every region starts from", + '#elements': "named regions sections can use with \"#element:\"", + '#resources': "images, fonts and sounds: names, atlases, image-mod", + '#pallet': "named colors", + '#override': "text for the special words (_A, _B, _CHECKED ...)", + '#animations': "named animations", + '#translations': "the theme's own translations: {lang: {English: translation}}", + } + +INFO_KEYS = { + 'name': {'type': "text"}, + 'creator': {'type': "text"}, + 'description': {'type': "text"}, + 'version': {'type': "text"}, + 'default-scheme': {'type': "scheme"}, + } + +## Keys of a section's "#config". +CONFIG_KEYS = { + 'buttons': {'type': "object"}, + } + +SPECIAL_WORDS = ['_A', '_B', '_X', '_Y', '_UP', '_DOWN', '_LEFT', '_RIGHT', '_START', '_SELECT', + '_L', '_R', '_CHECKED', '_UNCHECKED', '_MORE'] + +## Capabilities every device has one of, as well as what the themes use. +BASE_CAPABILITIES = ['640x480', '480x320', '720x720', '1280x720', '1920x1080', '4:3', '16:9', '3:2', '1:1', + 'wide', 'hires', 'lowres', 'opengl', 'power'] + + +_MISSING = object() + + +def _const(node): + """A literal's value, or _MISSING: Python 3.7 parses them as Str/Num/NameConstant, 3.8+ as Constant.""" + if isinstance(node, ast.Constant): + return node.value + for kind, field in (("Str", "s"), ("Num", "n"), ("NameConstant", "value")): + if hasattr(ast, kind) and type(node).__name__ == kind: + return getattr(node, field) + return _MISSING + + +def _literal(node, names): + """A literal option list, with the names we know (Rect.POINTS, BLEND_MODES) filled in.""" + if isinstance(node, ast.Attribute) and node.attr in names: + return names[node.attr] + if isinstance(node, ast.Name) and node.id in names: + return names[node.id] + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "list" and node.args: + return _literal(node.args[0], names) + try: + value = ast.literal_eval(node) + except (ValueError, TypeError, SyntaxError): + return None + if isinstance(value, (list, tuple)): + return [item for item in value if isinstance(item, str)] + return None + + +def _class_constants(tree): + """{'POINTS': [...], 'BLEND_MODES': [...]}: string tuples/lists and dict keys assigned in classes.""" + found = {} + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + value = node.value + if isinstance(value, ast.Dict): + keys = [_const(key) for key in value.keys if isinstance(_const(key), str)] + if keys: + found[node.targets[0].id] = keys + elif isinstance(value, (ast.Tuple, ast.List)): + items = [_const(item) for item in value.elts if isinstance(_const(item), str)] + if items and len(items) == len(value.elts): + found[node.targets[0].id] = items + return found + + +def region_keys(): + """{key: {'type': ..., 'options': [...], 'count': n, 'min': n, 'max': n}} of a region.""" + sdl = PUGWASH / "sdl" + names = {} + for path in sorted(sdl.glob("*.py")): + names.update(_class_constants(ast.parse(path.read_text(encoding="utf-8")))) + + keys = {} + tree = ast.parse((sdl / "region.py").read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr.startswith("_verify_")): + continue + if not node.args or not isinstance(_const(node.args[0]), str): + continue + + key, kind = _const(node.args[0]), node.func.attr[len("_verify_"):] + entry = {'type': kind} + keywords = {keyword.arg: keyword.value for keyword in node.keywords} + if kind == "option": + options = _literal(node.args[1], names) if len(node.args) > 1 else None + if options: + entry['options'] = options + if 'length' in keywords and isinstance(_const(keywords['length']), int): + entry['count'] = _const(keywords['length']) + elif kind == "ints" and len(node.args) > 1 and isinstance(_const(node.args[1]), int): + entry['count'] = _const(node.args[1]) + for bound, name in (('minimum', 'min'), ('maximum', 'max')): + if bound in keywords and isinstance(_const(keywords[bound]), (int, float)): + entry[name] = _const(keywords[bound]) + keys.setdefault(key, entry) + + for key, entry in EXTRA_KEYS.items(): + keys.setdefault(key, dict(entry)) + + return dict(sorted(keys.items())) + + +def _read_theme(path): + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def sections(): + """{section: [region names]}: the default theme's sections and every scene's.""" + found = {} + for name, data in _read_theme(DEFAULT_THEME).items(): + if not name.startswith("#") and isinstance(data, dict): + found[name] = [region for region in data if not region.startswith("#")] + + for path in sorted((PUGWASH / "scenes").glob("*.py")): + for section, tags in re.findall(r'load_regions\(\s*["\']([a-z_]+)["\']\s*,\s*\[([^\]]*)\]', path.read_text(encoding="utf-8")): + regions = found.setdefault(section, []) + for tag in re.findall(r'["\']([a-z_]+)["\']', tags): + if tag not in regions: + regions.append(tag) + + return dict(sorted(found.items())) + + +def template_tags(): + """Every {tag} pugwash sets: "system.time_24hr", "port_info.title" ...""" + found = set() + pattern = re.compile(r'(?:set_data\(\s*|text_data\[\s*)f?["\']([a-z_]+\.[a-z0-9_]+)["\']') + for path in sorted(PUGWASH.rglob("*.py")): + found.update(pattern.findall(path.read_text(encoding="utf-8"))) + return sorted(found) + + +def capabilities(theme_files=()): + """The [requirements] themes use, split on | and ,.""" + found = set(BASE_CAPABILITIES) + for path in [DEFAULT_THEME, *theme_files]: + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + continue + for group in re.findall(r'\[([^\]"]+)\]"\s*:', text): + for item in re.split(r'[|,]', group): + item = item.strip().lstrip("!") + if item: + found.add(item) + return sorted(found) + + +def theme_files(theme_dir): + """Files in the theme a key can name, by kind: {'image': [...], 'font': [...], 'sound': [...]}.""" + kinds = {'image': ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp', '.svg'), + 'font': ('.ttf', '.otf'), 'sound': ('.ogg', '.wav', '.mp3')} + found = {kind: [] for kind in kinds} + if theme_dir is not None: + theme_dir = Path(theme_dir) + for path in sorted(theme_dir.rglob("*")): + if path.is_file(): + for kind, suffixes in kinds.items(): + if path.suffix.lower() in suffixes: + found[kind].append(path.relative_to(theme_dir).as_posix()) + ## PortMaster's own fonts work in every theme. + for path in sorted(RESOURCES.glob("*")): + if path.suffix.lower() in kinds['font'] or path.name == "NotoSans.tar.xz": + names = [path.name] if path.suffix.lower() in kinds['font'] else \ + ["NotoSansJP-Regular.ttf", "NotoSansKR-Regular.ttf", "NotoSansSC-Regular.ttf", "NotoSansTC-Regular.ttf", "NotoSansHK-Regular.ttf"] + found['font'] += [name for name in names if name not in found['font']] + return found + + +def theme_names(theme_data): + """Names the theme defines: pallet colors (every scheme's), resources and atlas parts, elements, schemes.""" + colors, resources, elements = set(), set(), set() + schemes = [name for name in theme_data.get("#schemes", {}) if isinstance(theme_data.get("#schemes"), dict) and not name.startswith("#")] + + def pallet(section): + if isinstance(section, dict) and isinstance(section.get("#pallet"), dict): + colors.update(section["#pallet"]) + if isinstance(section, dict) and isinstance(section.get("#resources"), dict): + for file_name, info in section["#resources"].items(): + resources.add(file_name) + if isinstance(info, dict): + if isinstance(info.get("name"), str): + resources.add(info["name"]) + if isinstance(info.get("atlas"), dict): + resources.update(info["atlas"]) + + pallet(theme_data) + if isinstance(theme_data.get("#schemes"), dict): + for scheme in theme_data["#schemes"].values(): + pallet(scheme) + if isinstance(theme_data.get("#elements"), dict): + elements.update(theme_data["#elements"]) + + return {'colors': sorted(colors), 'resources': sorted(resources), 'elements': sorted(elements), 'schemes': schemes} + + +def schema(theme_dir=None, other_themes=()): + """Everything the editor's autocomplete and checks need, for one theme.""" + data = _read_theme(Path(theme_dir) / "theme.json") if theme_dir is not None else {} + return { + 'top': TOP_KEYS, + 'info': INFO_KEYS, + 'config': CONFIG_KEYS, + 'keys': region_keys(), + 'sections': sections(), + 'tags': template_tags(), + 'capabilities': capabilities(([Path(theme_dir) / "theme.json"] if theme_dir is not None else []) + list(other_themes)), + 'special_words': SPECIAL_WORDS, + 'files': theme_files(theme_dir), + 'names': theme_names(data), + } diff --git a/PortMaster/pylibs/theme_server/serve.py b/PortMaster/pylibs/theme_server/serve.py index 148c4bd2..3e12c71a 100644 --- a/PortMaster/pylibs/theme_server/serve.py +++ b/PortMaster/pylibs/theme_server/serve.py @@ -41,7 +41,8 @@ from pathlib import Path -from theme_server import catalogue, translations as i18n_tool +from theme_server import catalogue, schema, translations as i18n_tool +from theme_server.editor import EditorError, MAX_THEME_SIZE, ThemeEditor HERE = Path(__file__).resolve().parent @@ -75,6 +76,8 @@ class Themes: """The themes the server shows: the default theme, then each theme in --themes.""" def __init__(self, themes_dir): self.themes_dir = Path(themes_dir).resolve() if themes_dir else None + ## The Edit view's unsaved theme.json texts, "~draft": rendered, never listed. + self.drafts = {} def all(self): found = {"default_theme": DEFAULT_THEME_DIR} @@ -88,6 +91,8 @@ def all(self): return found def get(self, theme_id): + if theme_id in self.drafts: + return self.drafts[theme_id] return self.all().get(theme_id) def info(self, theme_id, theme_dir): @@ -624,6 +629,21 @@ def do_GET(self): if url.path in ("/", "/index.html"): self.send(200, (HERE / "static" / "index.html").read_bytes(), "text/html; charset=utf-8") + elif url.path in STATIC_FILES: + ## The editor's CodeMirror: a fixed list, nothing else of the folder is served. + name, content_type, cache = STATIC_FILES[url.path] + self.send(200, (HERE / "static" / name).read_bytes(), content_type, cache=cache) + + elif url.path == "/api/schema": + theme_dir = self.server.themes.get(query.get('theme', "default_theme")) + if theme_dir is None: + self.send(404, {'error': "no such theme"}) + return + self.send(200, schema.schema(theme_dir, [path / "theme.json" for path in self.server.themes.all().values()])) + + elif url.path == "/api/theme_file": + self.send(200, self.server.editor.read(query.get('theme', ""))) + elif url.path == "/api/info": themes = self.server.themes self.send(200, { @@ -682,6 +702,9 @@ def do_GET(self): except WorkerError as error: self.send(500, {'error': str(error)}) + except EditorError as error: + self.send(error.status, {**error.extra, 'error': str(error)}) + except (BrokenPipeError, ConnectionResetError): pass @@ -696,7 +719,9 @@ def do_POST(self): try: length = int(self.headers.get("Content-Length") or 0) - if not 0 < length <= 256 * 1024: + ## A theme.json can be big (JSON escapes make it bigger still), a translation can't. + limit = 2 * MAX_THEME_SIZE if url.path in ("/api/theme_file", "/api/theme_draft") else 256 * 1024 + if not 0 < length <= limit: self.send(400, {'error': "bad request"}) return body = json.loads(self.rfile.read(length).decode("utf-8")) @@ -722,9 +747,26 @@ def do_POST(self): return self.send(200, {'ok': True, 'problems': problems}) + elif url.path in ("/api/theme_file", "/api/theme_draft", "/api/theme_copy"): + theme_id, text = body.get('theme'), body.get('text') + editor = self.server.editor + if url.path == "/api/theme_copy": + self.send(200, editor.copy(theme_id, body.get('name'), text if isinstance(text, str) else None)) + elif not isinstance(text, str): + self.send(400, {'error': "bad request"}) + elif url.path == "/api/theme_draft": + self.send(200, editor.draft(theme_id, text)) + elif not isinstance(body.get('version'), str): + self.send(400, {'error': "bad request"}) + else: + self.send(200, editor.save(theme_id, text, body['version'])) + else: self.send(404, {'error': "not found"}) + except EditorError as error: + self.send(error.status, {**error.extra, 'error': str(error)}) + except (ValueError, UnicodeDecodeError, AttributeError): self.send(400, {'error': "bad request"}) @@ -732,6 +774,14 @@ def do_POST(self): pass +STATIC_FILES = { + ## path: (file in static/, type, cached: CodeMirror only changes with PortMaster) + "/static/codemirror.js": ("codemirror/codemirror.js", "text/javascript; charset=utf-8", True), + "/static/codemirror.css": ("codemirror/codemirror.css", "text/css; charset=utf-8", True), + "/static/editor.js": ("editor.js", "text/javascript; charset=utf-8", False), + } + + class Server(ThreadingHTTPServer): daemon_threads = True verbose = False @@ -862,6 +912,7 @@ def interrupted(signum, frame): ## Managed: every request goes in PortMaster's theme_server.txt. server.themes, server.pool, server.shots, server.verbose = themes, pool, shots, options.verbose or report is not None + server.editor = ThemeEditor(themes, work_dir, DEFAULT_THEME_DIR) port = server.server_address[1] ## Up straight away; the first renderer starts meanwhile (6-15 seconds on a handheld). diff --git a/PortMaster/pylibs/theme_server/static/codemirror/LICENSE b/PortMaster/pylibs/theme_server/static/codemirror/LICENSE new file mode 100644 index 00000000..9018d33e --- /dev/null +++ b/PortMaster/pylibs/theme_server/static/codemirror/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2017 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/PortMaster/pylibs/theme_server/static/codemirror/codemirror.css b/PortMaster/pylibs/theme_server/static/codemirror/codemirror.css new file mode 100644 index 00000000..7c841812 --- /dev/null +++ b/PortMaster/pylibs/theme_server/static/codemirror/codemirror.css @@ -0,0 +1,7 @@ +/* CodeMirror 5.65.18, MIT License, see LICENSE. From cdnjs: codemirror.min.css addon/fold/foldgutter.min.css addon/hint/show-hint.min.css addon/lint/lint.min.css addon/dialog/dialog.min.css addon/search/matchesonscrollbar.min.css */ +.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0} +.CodeMirror-foldmarker{color:#00f;text-shadow:#b9f 1px 1px 2px,#b9f -1px -1px 2px,#b9f 1px -1px 2px,#b9f -1px 1px 2px;font-family:arial;line-height:.3;cursor:pointer}.CodeMirror-foldgutter{width:.7em}.CodeMirror-foldgutter-folded,.CodeMirror-foldgutter-open{cursor:pointer}.CodeMirror-foldgutter-open:after{content:"\25BE"}.CodeMirror-foldgutter-folded:after{content:"\25B8"} +.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff} +.CodeMirror-lint-markers{width:16px}.CodeMirror-lint-tooltip{background-color:#ffd;border:1px solid #000;border-radius:4px 4px 4px 4px;color:#000;font-family:monospace;font-size:10pt;overflow:hidden;padding:2px 5px;position:fixed;white-space:pre;white-space:pre-wrap;z-index:100;max-width:600px;opacity:0;transition:opacity .4s;-moz-transition:opacity .4s;-webkit-transition:opacity .4s;-o-transition:opacity .4s;-ms-transition:opacity .4s}.CodeMirror-lint-mark{background-position:left bottom;background-repeat:repeat-x}.CodeMirror-lint-mark-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=)}.CodeMirror-lint-mark-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==)}.CodeMirror-lint-marker{background-position:center center;background-repeat:no-repeat;cursor:pointer;display:inline-block;height:16px;width:16px;vertical-align:middle;position:relative}.CodeMirror-lint-message{padding-left:18px;background-position:top left;background-repeat:no-repeat}.CodeMirror-lint-marker-warning,.CodeMirror-lint-message-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=)}.CodeMirror-lint-marker-error,.CodeMirror-lint-message-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=)}.CodeMirror-lint-marker-multiple{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-position:right bottom;width:100%;height:100%}.CodeMirror-lint-line-error{background-color:rgba(183,76,81,.08)}.CodeMirror-lint-line-warning{background-color:rgba(255,211,0,.1)} +.CodeMirror-dialog{position:absolute;left:0;right:0;background:inherit;z-index:15;padding:.1em .8em;overflow:hidden;color:inherit}.CodeMirror-dialog-top{border-bottom:1px solid #eee;top:0}.CodeMirror-dialog-bottom{border-top:1px solid #eee;bottom:0}.CodeMirror-dialog input{border:none;outline:0;background:0 0;width:20em;color:inherit;font-family:monospace}.CodeMirror-dialog button{font-size:70%} +.CodeMirror-search-match{background:gold;border-top:1px solid orange;border-bottom:1px solid orange;-moz-box-sizing:border-box;box-sizing:border-box;opacity:.5} diff --git a/PortMaster/pylibs/theme_server/static/codemirror/codemirror.js b/PortMaster/pylibs/theme_server/static/codemirror/codemirror.js new file mode 100644 index 00000000..1e93c3ff --- /dev/null +++ b/PortMaster/pylibs/theme_server/static/codemirror/codemirror.js @@ -0,0 +1,17 @@ +/* CodeMirror 5.65.18 (https://codemirror.net/5/), MIT License, see LICENSE. From cdnjs: codemirror.min.js mode/javascript/javascript.min.js addon/edit/matchbrackets.min.js addon/edit/closebrackets.min.js addon/fold/foldcode.min.js addon/fold/foldgutter.min.js addon/fold/brace-fold.min.js addon/hint/show-hint.min.js addon/lint/lint.min.js addon/dialog/dialog.min.js addon/search/searchcursor.min.js addon/search/search.min.js addon/search/jump-to-line.min.js addon/scroll/annotatescrollbar.min.js addon/search/matchesonscrollbar.min.js addon/selection/active-line.min.js */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2t)return i;o.to==t&&(o.from!=o.to&&"before"==n?r=i:Fe=i),o.from==t&&(o.from!=o.to&&"before"!=n?r=i:Fe=i)}return null!=r?r:Fe}Ee=/[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/,Re=/[stwN]/,ze=/[LRr]/,Ie=/[Lb1n]/,Be=/[1n]/;var Ee,Re,ze,Ie,Be,Ge=function(e,t){var n="ltr"==t?"L":"R";if(0==e.length||"ltr"==t&&!Ee.test(e))return!1;for(var r,i=e.length,o=[],l=0;l=e.size)throw new Error("There is no line "+(t+e.first)+" in the document.");for(var n=e;!n.lines;)for(var r=0;;++r){var i=n.children[r],o=i.chunkSize();if(t=e.first&&tn?F(n,W(e,n).text.length):(e=W(e,(n=t).line).text.length,null==(t=n.ch)||e=this.string.length},g.prototype.sol=function(){return this.pos==this.lineStart},g.prototype.peek=function(){return this.string.charAt(this.pos)||void 0},g.prototype.next=function(){if(this.post},g.prototype.eatSpace=function(){for(var e=this.pos;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e},g.prototype.skipToEnd=function(){this.pos=this.string.length},g.prototype.skipTo=function(e){e=this.string.indexOf(e,this.pos);if(-1e.options.maxHighlightLength&&ft(e.doc.mode,r.state),o=At(e,t,r),i&&(r.state=i),t.stateAfter=r.save(!i),t.styles=o.styles,o.classes?t.styleClasses=o.classes:t.styleClasses&&(t.styleClasses=null),n===e.doc.highlightFrontier&&(e.doc.modeFrontier=Math.max(e.doc.modeFrontier,++e.doc.highlightFrontier))),t.styles}function Wt(n,r,e){var t=n.doc,i=n.display;if(!t.mode.startState)return new Ot(t,!0,r);var o=function(e,t,n){for(var r,i,o=e.doc,l=n?-1:t-(e.doc.mode.innerMode?1e3:100),s=t;lt.first&&W(t,o-1).stateAfter,s=l?Ot.fromSaved(t,l,o):new Ot(t,gt(t.mode),o);return t.iter(o,r,function(e){Ht(n,e.text,s);var t=s.line;e.stateAfter=t==r-1||t%5==0||t>=i.viewFrom&&tt.start)return o}throw new Error("Mode "+e.name+" failed to advance stream.")}Ot.prototype.lookAhead=function(e){var t=this.doc.getLine(this.line+e);return null!=t&&e>this.maxLookAhead&&(this.maxLookAhead=e),t},Ot.prototype.baseToken=function(e){if(!this.baseTokens)return null;for(;this.baseTokens[this.baseTokenPos]<=e;)this.baseTokenPos+=2;var t=this.baseTokens[this.baseTokenPos+1];return{type:t&&t.replace(/( |^)overlay .*/,""),size:this.baseTokens[this.baseTokenPos]-e}},Ot.prototype.nextLine=function(){this.line++,0e.options.maxHighlightLength?(s=!1,l&&Ht(e,t,r,c.pos),c.pos=t.length,null):zt(Pt(n,c,r.state,h),o);if(!h||(d=h[0].name)&&(f="m-"+(f?d+" "+f:d)),!s||u!=f){for(;a=t:l.to>t),(r=r||[]).push(new Ut(s,l.from,o?null:l.to)))}return r}(n,r,o),s=function(e,t,n){var r;if(e)for(var i=0;i=t:l.to>t))&&(l.from!=t||"bookmark"!=s.type||n&&!l.marker.insertLeft)||(o=null==l.from||(s.inclusiveLeft?l.from<=t:l.frome.lastLine())return t;var n,r=W(e,t);if(!on(e,r))return t;for(;n=Jt(r);)r=n.find(1,!0).line;return H(r)+1}function on(e,t){var n=Gt&&t.markedSpans;if(n)for(var r,i=0;in.maxLineLength&&(n.maxLineLength=t,n.maxLine=e)})}var un=function(e,t,n){this.text=e,Yt(this,t),this.height=n?n(this):1};un.prototype.lineNo=function(){return H(this)},$e(un);var cn={},hn={};function dn(e,t){if(!e||/^\s*$/.test(e))return null;t=t.addModeClass?hn:cn;return t[e]||(t[e]=e.replace(/\S+/g,"cm-$&"))}function fn(e,t){var n=ne("span",null,null,x?"padding-right: .1px":null),r={pre:ne("pre",[n],"CodeMirror-line"),content:n,col:0,pos:0,cm:e,trailingSpace:!1,splitSpaces:e.getOption("lineWrapping")};t.measure={};for(var i=0;i<=(t.rest?t.rest.length:0);i++){var o=i?t.rest[i-1]:t.line,l=void 0,l=(r.pos=0,r.addToken=gn,function(e){if(null!=tt)return tt;var t=y(e,document.createTextNode("AخA")),n=le(t,0,1).getBoundingClientRect(),t=le(t,1,2).getBoundingClientRect();return te(e),n&&n.left!=n.right&&(tt=t.right-n.right<3)}(e.display.measure)&&(l=Ve(o,e.doc.direction))&&(r.addToken=function(h,d){return function(e,t,n,r,i,o,l){n=n?n+" cm-force-border":"cm-force-border";for(var s=e.pos,a=s+t.length;;){for(var u=void 0,c=0;cs&&u.from<=s);c++);if(u.to>=a)return h(e,t,n,r,i,o,l);h(e,t.slice(0,u.to-s),n,r,null,o,l),r=null,t=t.slice(u.to-s),s=u.to}}}(r.addToken,l)),r.map=[],t!=e.display.externalMeasured&&H(o));!function(e,t,n){var r=e.markedSpans,i=e.text,o=0;if(r)for(var l,s,a,u,c,h,d,f=i.length,p=0,g=1,m="",v=0;;){if(v==p){a=u=c=s="",h=d=null,v=1/0;for(var y=[],b=void 0,w=0;wp||C.collapsed&&x.to==p&&x.from==p)){if(null!=x.to&&x.to!=p&&v>x.to&&(v=x.to,u=""),C.className&&(a+=" "+C.className),C.css&&(s=(s?s+";":"")+C.css),C.startStyle&&x.from==p&&(c+=" "+C.startStyle),C.endStyle&&x.to==v&&(b=b||[]).push(C.endStyle,x.to),C.title&&((d=d||{}).title=C.title),C.attributes)for(var S in C.attributes)(d=d||{})[S]=C.attributes[S];C.collapsed&&(!h||qt(h.marker,C)<0)&&(h=x)}else x.from>p&&v>x.from&&(v=x.from)}if(b)for(var L=0;Ln)return{map:e.measure.maps[i],cache:e.measure.caches[i],before:!0}}}function zn(e,t,n,r){return Gn(e,Bn(e,t),n,r)}function In(e,t){if(t>=e.display.viewFrom&&t=e.lineN&&tt)&&(i=(o=a-s)-1,a<=t&&(l="right")),null!=i){if(r=e[u+2],s==a&&n==(r.insertLeft?"left":"right")&&(l=n),"left"==n&&0==i)for(;u&&e[u-2]==e[u-3]&&e[u-1].insertLeft;)r=e[2+(u-=3)],l="left";if("right"==n&&i==a-s)for(;u=i.text.length?(t=i.text.length,e="before"):t<=0&&(t=0,e="after"),!a)return s("before"==e?t-1:t,"before"==e);function u(e,t,n){return s(n?e-1:e,1==a[t].level!=n)}var c=Pe(a,t,e),h=Fe,c=u(t,c,"before"==e);return null!=h&&(c.other=u(t,h,"before"!=e)),c}function tr(e,t){var n=0,t=(t=E(e.doc,t),e.options.lineWrapping||(n=cr(e.display)*t.ch),W(e.doc,t.line)),e=ln(t)+Dn(e.display);return{left:n,right:n,top:e,bottom:e+t.height}}function nr(e,t,n,r,i){e=F(e,t,n);return e.xRel=i,r&&(e.outside=r),e}function rr(e,t,n){var r=e.doc;if((n+=e.display.viewOffset)<0)return nr(r.first,0,null,-1,-1);var i=bt(r,n),o=r.first+r.size-1;if(o=a.bottom?1:0)}return c=We(e.text,c,1),nr(t,c,g,f,r-p)}(e,l,i,t,n),a=function(e,t){var n,r=Gt&&e.markedSpans;if(r)for(var i=0;it)&&(!n||qt(n,o.marker)<0)&&(n=o.marker)}return n}(l,s.ch+(0r},i,e)}}function or(e,t,n,r){return ir(e,t,n=n||Bn(e,t),Zn(e,t,Gn(e,n,r),"line").top)}function lr(e,t,n,r){return!(e.bottom<=n)&&(e.top>n||(r?e.left:e.right)>t)}function sr(n,r,i,o,l,s,a){var e,t=He(function(e){var e=l[e],t=1!=e.level;return lr(er(n,F(i,t?e.to:e.from,t?"before":"after"),"line",r,o),s,a,!0)},0,l.length-1),u=l[t];return 0a&&(u=l[t-1])),u}function ar(e,t,n,r,i,o,l){for(var l=ir(e,t,r,l),s=l.begin,a=l.end,u=(/\s/.test(t.text.charAt(a-1))&&a--,null),c=null,h=0;h=a||f.to<=s||(d=(d=Gn(e,r,1!=f.level?Math.min(a,f.to)-1:Math.max(s,f.from)).right)a?{from:u.from,to:a,level:u.level}:u}function ur(e){if(null!=e.cachedTextHeight)return e.cachedTextHeight;if(null==Un){Un=M("pre",null,"CodeMirror-line-like");for(var t=0;t<49;++t)Un.appendChild(document.createTextNode("x")),Un.appendChild(M("br"));Un.appendChild(document.createTextNode("x"))}y(e.measure,Un);var n=Un.offsetHeight/50;return 3=e.display.viewTo)return null;if((t-=e.display.viewFrom)<0)return null;for(var n=e.display.view,r=0;rt)&&(o.updateLineNumbers=t),e.curOp.viewChanged=!0,t>=o.viewTo?Gt&&nn(e.doc,t)o.viewFrom?yr(e):(o.viewFrom+=r,o.viewTo+=r):t<=o.viewFrom&&n>=o.viewTo?yr(e):t<=o.viewFrom?(l=br(e,n,n+r,1))?(o.view=o.view.slice(l.index),o.viewFrom=l.lineN,o.viewTo+=r):yr(e):n>=o.viewTo?(l=br(e,t,t,-1))?(o.view=o.view.slice(0,l.index),o.viewTo=l.lineN):yr(e):(l=br(e,t,t,-1),i=br(e,n,n+r,1),l&&i?(o.view=o.view.slice(0,l.index).concat(yn(e,l.lineN,i.lineN)).concat(o.view.slice(i.index)),o.viewTo+=r):yr(e)),o.externalMeasured);l&&(n=i.lineN&&t=r.viewTo||null!=(i=r.view[mr(e,t)]).node&&-1==L(r=i.changes||(i.changes=[]),n)&&r.push(n)}function yr(e){e.display.viewFrom=e.display.viewTo=e.doc.first,e.display.view=[],e.display.viewOffset=0}function br(e,t,n,r){var i,o=mr(e,t),l=e.display.view;if(!Gt||n==e.doc.first+e.doc.size)return{index:o,lineN:n};for(var s=e.display.viewFrom,a=0;a=e.display.viewTo||s.to().linet||t==n&&l.to==t)&&(r(Math.max(l.from,t),Math.min(l.to,n),1==l.level?"rtl":"ltr",o),i=!0)}i||r(t,n,"ltr")}(C,g||0,null==m?b:m,function(e,t,n,r){var i,o,l,s,a,u="ltr"==n,c=w(e,u?"left":"right"),h=w(t-1,u?"right":"left"),d=null==g&&0==e,f=null==m&&t==b,p=0==r,r=!C||r==C.length-1;h.top-c.top<=3?(i=(k?d:f)&&p?S:(u?c:h).left,a=(k?f:d)&&r?L:(u?h:c).right,T(i,c.top,a-i,c.bottom)):(a=u?(o=k&&d&&p?S:c.left,l=k?L:x(e,n,"before"),s=k?S:x(t,n,"after"),k&&f&&r?L:h.right):(o=k?x(e,n,"before"):S,l=!k&&d&&p?L:c.right,s=!k&&f&&r?S:h.left,k?x(t,n,"after"):L),T(o,c.top,l-o,c.bottom),c.bottome.display.sizerWidth&&((a=Math.ceil(c/cr(e.display)))>e.display.maxLineLength&&(e.display.maxLineLength=a,e.display.maxLine=s.line,e.display.maxLineChanged=!0))}}2=o&&(i=bt(t,ln(W(t,n))-e.wrapper.clientHeight),o=n)),{from:i,to:Math.max(o,i+1)}}function Hr(e,t){var n=e.display,r=ur(e.display),i=(t.top<0&&(t.top=0),(e.curOp&&null!=e.curOp.scrollTop?e.curOp:n.scroller).scrollTop),o=En(e),l={},s=(t.bottom-t.top>o&&(t.bottom=t.top+o),e.doc.height+Wn(n)),a=t.tops-r,r=(t.topi+o&&((a=Math.min(t.top,(r?s:t.bottom)-o))!=i&&(l.scrollTop=a)),e.options.fixedGutter?0:n.gutters.offsetWidth),s=e.curOp&&null!=e.curOp.scrollLeft?e.curOp.scrollLeft:n.scroller.scrollLeft-r,o=Pn(e)-n.gutters.offsetWidth,i=t.right-t.left>o;return i&&(t.right=t.left+o),t.left<10?l.scrollLeft=0:t.lefto+s-3&&(l.scrollLeft=t.right+(i?0:10)-o),l}function Fr(e,t){null!=t&&(Rr(e),e.curOp.scrollTop=(null==e.curOp.scrollTop?e.doc:e.curOp).scrollTop+t)}function Pr(e){Rr(e);var t=e.getCursor();e.curOp.scrollToPos={from:t,to:t,margin:e.options.cursorScrollMargin}}function Er(e,t,n){null==t&&null==n||Rr(e),null!=t&&(e.curOp.scrollLeft=t),null!=n&&(e.curOp.scrollTop=n)}function Rr(e){var t=e.curOp.scrollToPos;t&&(e.curOp.scrollToPos=null,zr(e,tr(e,t.from),tr(e,t.to),t.margin))}function zr(e,t,n,r){t=Hr(e,{left:Math.min(t.left,n.left),top:Math.min(t.top,n.top)-r,right:Math.max(t.right,n.right),bottom:Math.max(t.bottom,n.bottom)+r});Er(e,t.scrollLeft,t.scrollTop)}function Ir(e,t){Math.abs(e.doc.scrollTop-t)<2||(d||ri(e,{top:t}),Br(e,t,!0),d&&ri(e),Qr(e,100))}function Br(e,t,n){t=Math.max(0,Math.min(e.display.scroller.scrollHeight-e.display.scroller.clientHeight,t)),e.display.scroller.scrollTop==t&&!n||(e.doc.scrollTop=t,e.display.scrollbars.setScrollTop(t),e.display.scroller.scrollTop!=t&&(e.display.scroller.scrollTop=t))}function Gr(e,t,n,r){t=Math.max(0,Math.min(t,e.display.scroller.scrollWidth-e.display.scroller.clientWidth)),(n?t==e.doc.scrollLeft:Math.abs(e.doc.scrollLeft-t)<2)&&!r||(e.doc.scrollLeft=t,li(e),e.display.scroller.scrollLeft!=t&&(e.display.scroller.scrollLeft=t),e.display.scrollbars.setScrollLeft(t))}function Ur(e){var t=e.display,n=t.gutters.offsetWidth,r=Math.round(e.doc.height+Wn(e.display));return{clientHeight:t.scroller.clientHeight,viewHeight:t.wrapper.clientHeight,scrollWidth:t.scroller.scrollWidth,clientWidth:t.scroller.clientWidth,viewWidth:t.wrapper.clientWidth,barLeft:e.options.fixedGutter?n:0,docHeight:r,scrollHeight:r+Fn(e)+t.barHeight,nativeBarWidth:t.nativeBarWidth,gutterWidth:n}}function Vr(e,t,n){this.cm=n;var r=this.vert=M("div",[M("div",null,null,"min-width: 1px")],"CodeMirror-vscrollbar"),i=this.horiz=M("div",[M("div",null,null,"height: 100%; min-height: 1px")],"CodeMirror-hscrollbar");r.tabIndex=i.tabIndex=-1,e(r),e(i),k(r,"scroll",function(){r.clientHeight&&t(r.scrollTop,"vertical")}),k(i,"scroll",function(){i.clientWidth&&t(i.scrollLeft,"horizontal")}),this.checkedZeroWidth=!1,w&&v<8&&(this.horiz.style.minHeight=this.vert.style.minWidth="18px")}function Kr(){}Vr.prototype.update=function(e){var t,n=e.scrollWidth>e.clientWidth+1,r=e.scrollHeight>e.clientHeight+1,i=e.nativeBarWidth;return r?(this.vert.style.display="block",this.vert.style.bottom=n?i+"px":"0",t=e.viewHeight-(n?i:0),this.vert.firstChild.style.height=Math.max(0,e.scrollHeight-e.clientHeight+t)+"px"):(this.vert.scrollTop=0,this.vert.style.display="",this.vert.firstChild.style.height="0"),n?(this.horiz.style.display="block",this.horiz.style.right=r?i+"px":"0",this.horiz.style.left=e.barLeft+"px",t=e.viewWidth-e.barLeft-(r?i:0),this.horiz.firstChild.style.width=Math.max(0,e.scrollWidth-e.clientWidth+t)+"px"):(this.horiz.style.display="",this.horiz.firstChild.style.width="0"),!this.checkedZeroWidth&&0=l.viewTo)||l.maxLineChanged&&o.options.lineWrapping,i.update=i.mustUpdate&&new ei(o,i.mustUpdate&&{top:i.scrollTop,ensure:i.scrollToPos},i.forceUpdate)}for(var s=0;s(i.defaultView.innerHeight||i.documentElement.clientHeight)&&(r=!1),null==r||X||(o=M("div","​",null,"position: absolute;\n top: "+(t.top-n.viewOffset-Dn(e.display))+"px;\n height: "+(t.bottom-t.top+Fn(e)+n.barHeight)+"px;\n left: "+t.left+"px; width: "+Math.max(2,t.right-t.left)+"px;"),e.display.lineSpace.appendChild(o),o.scrollIntoView(r),e.display.lineSpace.removeChild(o)))}(w,v));var S=b.maybeHiddenMarkers,L=b.maybeUnhiddenMarkers;if(S)for(var k=0;k=l.display.viewTo||(s=+new Date+l.options.workTime,a=Wt(l,c.highlightFrontier),u=[],c.iter(a.line,Math.min(c.first+c.size,l.display.viewTo+500),function(e){if(a.line>=l.display.viewFrom){for(var t=e.styles,n=e.text.length>l.options.maxHighlightLength?ft(c.mode,a.state):null,r=At(l,e,a,!0),n=(n&&(a.state=n),e.styles=r.styles,e.styleClasses),r=r.classes,i=(r?e.styleClasses=r:n&&(e.styleClasses=null),!t||t.length!=e.styles.length||n!=r&&(!n||!r||n.bgClass!=r.bgClass||n.textClass!=r.textClass)),o=0;!i&&os)return Qr(l,l.options.workDelay),!0}),c.highlightFrontier=a.line,c.modeFrontier=Math.max(c.modeFrontier,a.line),u.length&&h(l,function(){for(var e=0;e=n.viewFrom&&t.visible.to<=n.viewTo&&(null==n.updateLineNumbers||n.updateLineNumbers>=n.viewTo)&&n.renderedView==n.view&&0==wr(e))return!1;si(e)&&(yr(e),t.dims=hr(e));var i=r.first+r.size,o=Math.max(t.visible.from-e.options.viewportMargin,r.first),l=Math.min(i,t.visible.to+e.options.viewportMargin),r=(n.viewFroml&&n.viewTo-l<20&&(l=Math.min(i,n.viewTo)),Gt&&(o=nn(e.doc,o),l=rn(e.doc,l)),o!=n.viewFrom||l!=n.viewTo||n.lastWrapHeight!=t.wrapperHeight||n.lastWrapWidth!=t.wrapperWidth),i=(i=o,o=l,0==(c=(l=e).display).view.length||i>=c.viewTo||o<=c.viewFrom?(c.view=yn(l,i,o),c.viewFrom=i):(c.viewFrom>i?c.view=yn(l,i,c.viewFrom).concat(c.view):c.viewFromo&&(c.view=c.view.slice(0,mr(l,o)))),c.viewTo=o,n.viewOffset=ln(W(e.doc,n.viewFrom)),e.display.mover.style.top=n.viewOffset+"px",wr(e));if(!r&&0==i&&!t.force&&n.renderedView==n.view&&(null==n.updateLineNumbers||n.updateLineNumbers>=n.viewTo))return!1;var l=function(e){if(e.hasFocus())return null;if(!(n=N(ue(e)))||!re(e.display.lineDiv,n))return null;var t,n={activeElt:n};return window.getSelection&&(t=he(e).getSelection()).anchorNode&&t.extend&&re(e.display.lineDiv,t.anchorNode)&&(n.anchorNode=t.anchorNode,n.anchorOffset=t.anchorOffset,n.focusNode=t.focusNode,n.focusOffset=t.focusOffset),n}(e),s=(4=e.display.viewFrom&&t.visible.to<=e.display.viewTo)break;if(!ti(e,t))break;Ar(e);var i=Ur(e);xr(e),jr(e,i),oi(e,i),t.force=!1}t.signal(e,"update",e),e.display.viewFrom==e.display.reportedViewFrom&&e.display.viewTo==e.display.reportedViewTo||(t.signal(e,"viewportChange",e,e.display.viewFrom,e.display.viewTo),e.display.reportedViewFrom=e.display.viewFrom,e.display.reportedViewTo=e.display.viewTo)}function ri(e,t){var n,t=new ei(e,t);ti(e,t)&&(Ar(e),ni(e,t),n=Ur(e),xr(e),jr(e,n),oi(e,n),t.finish())}function ii(e){var t=e.gutters.offsetWidth;e.sizer.style.marginLeft=t+"px",b(e,"gutterChanged",e)}function oi(e,t){e.display.sizer.style.minHeight=t.docHeight+"px",e.display.heightForcer.style.top=t.docHeight+"px",e.display.gutters.style.height=t.docHeight+e.display.barHeight+Fn(e)+"px"}function li(e){var t=e.display,n=t.view;if(t.alignWidgets||t.gutters.firstChild&&e.options.fixedGutter){for(var r=dr(t)-t.scroller.scrollLeft+e.doc.scrollLeft,i=t.gutters.offsetWidth,o=r+"px",l=0;ll.clientWidth,a=l.scrollHeight>l.clientHeight;if(r&&s||n&&a){if(n&&C&&x)e:for(var u=t.target,c=o.view;u!=l;u=u.parentNode)for(var h=0;hs-(e.cm?e.cm.options.historyEventDelay:500)||"*"==t.origin.charAt(0)))&&(o=(o=l).lastOp==r?(Wi(o.done),z(o.done)):o.done.length&&!z(o.done).ranges?z(o.done):1l.undoDepth;)l.done.shift(),l.done[0].ranges||l.done.shift()}l.done.push(n),l.generation=++l.maxGeneration,l.lastModTime=l.lastSelTime=s,l.lastOp=l.lastSelOp=r,l.lastOrigin=l.lastSelOrigin=t.origin,i||O(e,"historyAdded")}function Fi(e,t,n,r){var i,o,l,s=e.history,a=r&&r.origin;n==s.lastSelOp||a&&s.lastSelOrigin==a&&(s.lastModTime==s.lastSelTime&&s.lastOrigin==a||(e=e,i=a,o=z(s.done),l=t,"*"==(i=i.charAt(0))||"+"==i&&o.ranges.length==l.ranges.length&&o.somethingSelected()==l.somethingSelected()&&new Date-e.history.lastSelTime<=(e.cm?e.cm.options.historyEventDelay:500)))?s.done[s.done.length-1]=t:Pi(t,s.done),s.lastSelTime=+new Date,s.lastSelOrigin=a,s.lastSelOp=n,r&&!1!==r.clearRedo&&Wi(s.undone)}function Pi(e,t){var n=z(t);n&&n.ranges&&n.equals(e)||t.push(e)}function Ei(t,n,e,r){var i=n["spans_"+t.id],o=0;t.iter(Math.max(t.first,e),Math.min(t.first+t.size,r),function(e){e.markedSpans&&((i=i||(n["spans_"+t.id]={}))[o]=e.markedSpans),++o})}function Ri(e,t){var n=t["spans_"+e.id];if(!n)return null;for(var r=[],i=0;i=t.ch:s.to>t.ch))){if(i&&(O(a,"beforeCursorEnter"),a.explicitlyCleared)){if(o.markedSpans){--l;continue}break}if(a.atomic){if(n){var s=a.find(r<0?1:-1),h=void 0;if((s=(r<0?c:u)?Qi(e,s,-r,s&&s.line==t.line?o:null):s)&&s.line==t.line&&(h=P(s,n))&&(r<0?h<0:0e.first?E(e,F(t.line-1)):null:0e.lastLine())){t.from.linei?{from:t.from,to:F(i,W(e,i).text.length),text:[t.text[0]],origin:t.origin}:t).removed=mt(e,t.from,t.to),n=n||xi(e,t),e.cm){var i=e.cm,o=t,l=r,s=i.doc,a=i.display,u=o.from,c=o.to,h=!1,d=u.line,f=(i.options.lineWrapping||(d=H(tn(W(s,u.line))),s.iter(d,c.line+1,function(e){if(e==a.maxLine)return h=!0})),-1a.maxLineLength&&(a.maxLine=e,a.maxLineLength=t,a.maxLineChanged=!0,h=!1)}),h&&(i.curOp.updateMaxLine=!0)),s),p=u.line;if(f.modeFrontier=Math.min(f.modeFrontier,p),!(f.highlightFrontiert.display.maxLineLength&&(t.display.maxLine=u,t.display.maxLineLength=c,t.display.maxLineChanged=!0)}null!=r&&t&&this.collapsed&&R(t,r,i+1),this.lines.length=0,this.explicitlyCleared=!0,this.atomic&&this.doc.cantEdit&&(this.doc.cantEdit=!1,t&&$i(t.doc)),t&&b(t,"markerCleared",t,this,r,i),n&&Zr(t),this.parent&&this.parent.clear()}},mo.prototype.find=function(e,t){var n,r;null==e&&"bookmark"==this.type&&(e=1);for(var i=0;i=e.ch)&&t.push(i.marker.parent||i.marker)}return t},findMarks:function(i,o,l){i=E(this,i),o=E(this,o);var s=[],a=i.line;return this.iter(i.line,o.line+1,function(e){var t=e.markedSpans;if(t)for(var n=0;n=r.to||null==r.from&&a!=i.line||null!=r.from&&a==o.line&&r.from>=o.ch||l&&!l(r.marker)||s.push(r.marker.parent||r.marker)}++a}),s},getAllMarks:function(){var r=[];return this.iter(function(e){var t=e.markedSpans;if(t)for(var n=0;nt&&(t=e.from),null!=e.to&&e.toe.text.length?null:t}function Ko(e,t,n){e=Vo(e,t.ch,n);return null==e?null:new F(t.line,e,n<0?"after":"before")}function jo(e,t,n,r,i){if(e){"rtl"==t.doc.direction&&(i=-i);var o,l,s,a,e=Ve(n,t.doc.direction);if(e)return o=i<0==(1==(e=i<0?z(e):e[0]).level)?"after":"before",0=n.text.length?(s.ch=n.text.length,s.sticky="before"):s.ch<=0&&(s.ch=0,s.sticky="after");var r=Pe(a,s.ch,s.sticky),i=a[r];if("ltr"==t.doc.direction&&i.level%2==0&&(0s.ch:i.from=i.from&&d>=c.begin))return new F(s.line,d,h?"before":"after")}function f(e,t,n){for(var r=function(e,t){return t?new F(s.line,u(e,1),"before"):new F(s.line,e,"after")};0<=e&&el.doc.first&&((n=W(l.doc,e.line-1).text)&&(e=new F(e.line,1),l.replaceRange(t.charAt(0)+l.doc.lineSeparator()+n.charAt(n.length-1),F(e.line-1,n.length-1),e,"+transpose")))),i.push(new G(e,e)));l.setSelections(i)})},newlineAndIndent:function(r){return h(r,function(){for(var e=(t=r.listSelections()).length-1;0<=e;e--)r.replaceRange(r.doc.lineSeparator(),t[e].anchor,t[e].head,"+input");for(var t=r.listSelections(),n=0;nc&&t.push(new G(F(s,c),F(s,we(u,l,n))))}t.length||t.push(new G(f,f)),U(g,vi(d,y.ranges.slice(0,v).concat(t),v),{origin:"*mouse",scroll:!1}),d.scrollIntoView(e)}else{var h,r=m,i=ul(d,e,p.unit),e=r.anchor,e=0=n.to||o.linea.bottom?20:0)&&setTimeout(I(d,function(){u==i&&(l.scroller.scrollTop+=r,e(t))}),50))}:n)(e)}),i=I(d,n);d.state.selectingText=i,k(l.wrapper.ownerDocument,"mousemove",r),k(l.wrapper.ownerDocument,"mouseup",i)})(i,s,o,a)):Qe(e)==h.scroller&&D(e):2==n?(t&&Gi(c.doc,t),setTimeout(function(){return h.input.focus()},20)):3==n&&(Q?c.display.input.onContextMenu(e):Mr(c)))))}function ul(e,t,n){if("char"==n)return new G(t,t);if("word"==n)return e.findWordAt(t);if("line"==n)return new G(F(t.line,0),E(e.doc,F(t.line+1,0)));n=n(e,t);return new G(n.from,n.to)}function cl(e,t,n,r){var i,o;if(t.touches)i=t.touches[0].clientX,o=t.touches[0].clientY;else try{i=t.clientX,o=t.clientY}catch(e){return!1}if(i>=Math.floor(e.display.gutters.getBoundingClientRect().right))return!1;r&&D(t);var l=e.display,r=l.lineDiv.getBoundingClientRect();if(o>r.bottom||!Ye(e,n))return qe(t);o-=r.top-l.viewOffset;for(var s=0;s=i)return O(e,n,e,bt(e.doc,o),e.display.gutterSpecs[s].className,t),qe(t)}}function hl(e,t){return cl(e,t,"gutterClick",!0)}function dl(e,t){var n,r;An(e.display,t)||(r=t,Ye(n=e,"gutterContextMenu")&&cl(n,r,"gutterContextMenu",!1))||A(e,t,"contextmenu")||Q||e.display.input.onContextMenu(t)}function fl(e){e.display.wrapper.className=e.display.wrapper.className.replace(/\s*cm-s-\S+/g,"")+e.options.theme.replace(/(^|\s)\s*/g," cm-s-"),Yn(e)}ol.prototype.compare=function(e,t,n){return this.time+400>e&&0==P(t,this.pos)&&n==this.button};var pl={toString:function(){return"CodeMirror.Init"}},gl={},ml={};function vl(e,t,n){!t!=!(n&&n!=pl)&&(n=e.display.dragFunctions,(t=t?k:T)(e.display.scroller,"dragstart",n.start),t(e.display.scroller,"dragenter",n.enter),t(e.display.scroller,"dragover",n.over),t(e.display.scroller,"dragleave",n.leave),t(e.display.scroller,"drop",n.drop))}function yl(e){e.options.lineWrapping?(ie(e.display.wrapper,"CodeMirror-wrap"),e.display.sizer.style.minWidth="",e.display.sizerWidth=null):(ee(e.display.wrapper,"CodeMirror-wrap"),an(e)),pr(e),R(e),Yn(e),setTimeout(function(){return jr(e)},100)}function p(e,t){var n=this;if(!(this instanceof p))return new p(e,t);this.options=t=t?fe(t):{},fe(gl,t,!1);var r,i=t.value,o=("string"==typeof i?i=new f(i,t.mode,null,t.lineSeparator,t.direction):t.mode&&(i.modeOption=t.mode),this.doc=i,new p.inputStyles[t.inputStyle](this)),e=this.display=new hi(e,i,o,t),l=(fl(e.wrapper.CodeMirror=this),t.lineWrapping&&(this.display.wrapper.className+=" CodeMirror-wrap"),$r(this),this.state={keyMaps:[],overlays:[],modeGen:0,overwrite:!1,delayingBlurEvent:!1,focused:!1,suppressEdits:!1,pasteIncoming:-1,cutIncoming:-1,selectingText:!1,draggingText:!1,highlight:new pe,keySeq:null,specialChars:null},t.autofocus&&!_&&e.input.focus(),w&&v<11&&setTimeout(function(){return n.display.input.reset(!0)},20),this),s=l.display;k(s.scroller,"mousedown",I(l,al)),k(s.scroller,"dblclick",w&&v<11?I(l,function(e){var t;A(l,e)||(!(t=gr(l,e))||hl(l,e)||An(l.display,e)||(D(e),e=l.findWordAt(t),Gi(l.doc,e.anchor,e.head)))}):function(e){return A(l,e)||D(e)}),k(s.scroller,"contextmenu",function(e){return dl(l,e)}),k(s.input.getField(),"contextmenu",function(e){s.scroller.contains(e.target)||dl(l,e)});var a,u={end:0};function c(){s.activeTouch&&(a=setTimeout(function(){return s.activeTouch=null},1e3),(u=s.activeTouch).end=+new Date)}function h(e,t){if(null==t.left)return 1;var n=t.left-e.left,t=t.top-e.top;return 400o.first?S(W(o,t-1).text,null,l):0:"add"==n?c=a+e.options.indentUnit:"subtract"==n?c=a-e.options.indentUnit:"number"==typeof n&&(c=a+n);var c=Math.max(0,c),h="",d=0;if(e.options.indentWithTabs)for(var f=Math.floor(c/l);f;--f)d+=l,h+="\t";if(dl,a=rt(t),u=null;if(s&&1l?"cut":"+input")});to(e.doc,f),b(e,"inputRead",e,f)}t&&!s&&kl(e,t),Pr(e),e.curOp.updateInput<2&&(e.curOp.updateInput=h),e.curOp.typing=!0,e.state.pasteIncoming=e.state.cutIncoming=-1}function Ll(e,t){var n=e.clipboardData&&e.clipboardData.getData("Text");return n&&(e.preventDefault(),t.isReadOnly()||t.options.disableInput||!t.hasFocus()||h(t,function(){return Sl(t,n,0,null,"paste")}),1)}function kl(e,t){if(e.options.electricChars&&e.options.smartIndent)for(var n=e.doc.sel,r=n.ranges.length-1;0<=r;r--){var i=n.ranges[r];if(!(100=n.first+n.size||(r=new F(e,r.ch,r.sticky),!(s=W(n,e))))return;r=jo(l,n.cm,s,r.line,a)}else r=t;return 1}if("char"==o||"codepoint"==o)u();else if("column"==o)u(!0);else if("word"==o||"group"==o)for(var c=null,h="group"==o,d=n.cm&&n.cm.getHelper(r,"wordChars"),f=!0;!(i<0)||u(!f);f=!1){var p=s.text.charAt(r.ch)||"\n",p=Ne(p,d)?"w":h&&"\n"==p?"n":!h||/\s/.test(p)?null:"p";if(!h||f||p||(p="s"),c&&c!=p){i<0&&(i=1,u(),r.sticky="after");break}if(p&&(c=p),0=s.height){l.hitSide=!0;break}o+=5*n}return l}function r(e){this.cm=e,this.lastAnchorNode=this.lastAnchorOffset=this.lastFocusNode=this.lastFocusOffset=null,this.polling=new pe,this.composing=null,this.gracePeriod=!1,this.readDOMTimeout=null}function Dl(e,t){var n=In(e,t.line);if(!n||n.hidden)return null;var r=W(e.doc,t.line),n=Rn(n,r,t.line),r=Ve(r,e.doc.direction),e="left",r=(r&&(e=Pe(r,t.ch)%2?"right":"left"),Kn(n.map,t.ch,e));return r.offset="right"==r.collapse?r.end:r.start,r}function Wl(e,t){return t&&(e.bad=!0),e}function Hl(e,t,n){var r;if(t==e.display.lineDiv){if(!(r=e.display.lineDiv.childNodes[n]))return Wl(e.clipPos(F(e.display.viewTo-1)),!0);t=null,n=0}else for(r=t;;r=r.parentNode){if(!r||r==e.display.lineDiv)return null;if(r.parentNode&&r.parentNode==e.display.lineDiv)break}for(var i=0;i=t.display.viewTo||n.line=t.display.viewFrom&&Dl(t,r)||{node:i[0].measure.map[2],offset:0},r=n.linet.firstLine()&&(i=F(i.line-1,W(t.doc,i.line-1).length)),r.ch==W(t.doc,r.line).text.length&&r.linen.viewTo-1)return!1;var o,l=i.line==n.viewFrom||0==(l=mr(t,i.line))?(e=H(n.view[0].line),n.view[0].node):(e=H(n.view[l].line),n.view[l-1].node.nextSibling),r=mr(t,r.line),n=r==n.view.length-1?(o=n.viewTo-1,n.lineDiv.lastChild):(o=H(n.view[r+1].line)-1,n.view[r+1].node.previousSibling);if(!l)return!1;for(var s=t.doc.splitLines(function(o,e,t,l,s){var n="",a=!1,u=o.doc.lineSeparator(),c=!1;function h(){a&&(n+=u,c&&(n+=u),a=c=!1)}function d(e){e&&(h(),n+=e)}for(;!function e(t){if(1==t.nodeType){var n=t.getAttribute("cm-text");if(n)d(n);else if(n=t.getAttribute("cm-marker"))(n=o.findMarks(F(l,0),F(s+1,0),(i=+n,function(e){return e.id==i}))).length&&(n=n[0].find(0))&&d(mt(o.doc,n.from,n.to).join(u));else if("false"!=t.getAttribute("contenteditable")&&(n=/^(pre|div|p|li|table|br)$/i.test(t.nodeName),/^br$/i.test(t.nodeName)||0!=t.textContent.length)){n&&h();for(var r=0;ri.ch&&p.charCodeAt(p.length-c-1)==g.charCodeAt(g.length-c-1);)u--,c++;s[s.length-1]=p.slice(0,p.length-c).replace(/^\u200b+/,""),s[0]=s[0].slice(u).replace(/\u200b+$/,"");r=F(e,u),l=F(o,a.length?z(a).length-c:0);return 1n&&(wl(this,i.head.line,e,!0),n=i.head.line,r==this.doc.sel.primIndex&&Pr(this));else{for(var o=i.from(),i=i.to(),l=Math.max(n,o.line),n=Math.min(this.lastLine(),i.line-(i.ch?0:1))+1,s=l;s>1;if((l?n[2*l-1]:0)>=o)i=l;else{if(!(n[2*l+1]l)&&e.top>t.offsetHeight?a=e.top-t.offsetHeight:e.bottom+t.offsetHeight<=l&&(a=e.bottom),u+t.offsetWidth>o&&(u=o-t.offsetWidth)),t.style.top=a+"px",t.style.left=t.style.right="","right"==i?(u=s.sizer.clientWidth-t.offsetWidth,t.style.right="0px"):("left"==i?u=0:"middle"==i&&(u=(s.sizer.clientWidth-t.offsetWidth)/2),t.style.left=u+"px"),n&&(r=this,l={left:u,top:a,right:u+t.offsetWidth,bottom:a+t.offsetHeight},null!=(l=Hr(r,l)).scrollTop&&Ir(r,l.scrollTop),null!=l.scrollLeft&&Gr(r,l.scrollLeft))},triggerOnKeyDown:t(nl),triggerOnKeyPress:t(il),triggerOnKeyUp:rl,triggerOnMouseDown:t(al),execCommand:function(e){if(Yo.hasOwnProperty(e))return Yo[e].call(null,this)},triggerElectric:t(function(e){kl(this,e)}),findPosH:function(e,t,n,r){for(var i=1,o=(t<0&&(i=-1,t=-t),E(this.doc,e)),l=0;l!?|~^@]/,J=/^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;function i(e,t,r){return H=e,D=r,t}function d(e,t){var a,r=e.next();if('"'==r||"'"==r)return t.tokenize=(a=r,function(e,t){var r,n=!1;if(U&&"@"==e.peek()&&e.match(J))return t.tokenize=d,i("jsonld-keyword","meta");for(;null!=(r=e.next())&&(r!=a||n);)n=!n&&"\\"==r;return n||(t.tokenize=d),i("string","string")}),t.tokenize(e,t);if("."==r&&e.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/))return i("number","number");if("."==r&&e.match(".."))return i("spread","meta");if(/[\[\]{}\(\),;\:\.]/.test(r))return i(r);if("="==r&&e.eat(">"))return i("=>","operator");if("0"==r&&e.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/))return i("number","number");if(/\d/.test(r))return e.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/),i("number","number");if("/"==r)return e.eat("*")?(t.tokenize=K)(e,t):e.eat("/")?(e.skipToEnd(),i("comment","comment")):tt(e,t,1)?(function(e){for(var t,r=!1,n=!1;null!=(t=e.next());){if(!r){if("/"==t&&!n)return;"["==t?n=!0:n&&"]"==t&&(n=!1)}r=!r&&"\\"==t}}(e),e.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/),i("regexp","string-2")):(e.eat("="),i("operator","operator",e.current()));if("`"==r)return(t.tokenize=L)(e,t);if("#"==r&&"!"==e.peek())return e.skipToEnd(),i("meta","meta");if("#"==r&&e.eatWhile(B))return i("variable","property");if("<"==r&&e.match("!--")||"-"==r&&e.match("->")&&!/\S/.test(e.string.slice(0,e.start)))return e.skipToEnd(),i("comment","comment");if(G.test(r))return">"==r&&t.lexical&&">"==t.lexical.type||(e.eat("=")?"!"!=r&&"="!=r||e.eat("="):/[<>*+\-|&?]/.test(r)&&(e.eat(r),">"==r&&e.eat(r))),"?"==r&&e.eat(".")?i("."):i("operator","operator",e.current());if(B.test(r)){e.eatWhile(B);r=e.current();if("."!=t.lastType){if(F.propertyIsEnumerable(r))return i((t=F[r]).type,t.style,r);if("async"==r&&e.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/,!1))return i("async","keyword",r)}return i("variable","variable",r)}}function K(e,t){for(var r,n=!1;r=e.next();){if("/"==r&&n){t.tokenize=d;break}n="*"==r}return i("comment","comment")}function L(e,t){for(var r,n=!1;null!=(r=e.next());){if(!n&&("`"==r||"$"==r&&e.eat("{"))){t.tokenize=d;break}n=!n&&"\\"==r}return i("quasi","string-2",e.current())}function Q(e,t){t.fatArrowAt&&(t.fatArrowAt=null);var r=e.string.indexOf("=>",e.start);if(!(r<0)){!u||(n=/:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(e.string.slice(e.start,r)))&&(r=n.index);for(var n,a=0,i=!1,o=r-1;0<=o;--o){var c=e.string.charAt(o),s="([{}])".indexOf(c);if(0<=s&&s<3){if(!a){++o;break}if(0==--a){"("==c&&(i=!0);break}}else if(3<=s&&s<6)++a;else if(B.test(c))i=!0;else if(/["'\/`]/.test(c))for(;;--o){if(0==o)return;if(e.string.charAt(o-1)==c&&"\\"!=e.string.charAt(o-2)){o--;break}}else if(i&&!a){++o;break}}i&&!a&&(t.fatArrowAt=o)}}var R={atom:!0,number:!0,variable:!0,string:!0,regexp:!0,this:!0,import:!0,"jsonld-keyword":!0};function X(e,t,r,n,a,i){this.indented=e,this.column=t,this.type=r,this.prev=a,this.info=i,null!=n&&(this.align=n)}function Y(e,t,r,n,a){var i=e.cc;for(c.state=e,c.stream=a,c.marked=null,c.cc=i,c.style=t,e.lexical.hasOwnProperty("align")||(e.lexical.align=!0);;)if((i.length?i.pop():o?x:b)(r,n)){for(;i.length&&i[i.length-1].lex;)i.pop()();return c.marked?c.marked:"variable"==r&&function(e,t){if(W){for(var r=e.localVars;r;r=r.next)if(r.name==t)return 1;for(var n=e.context;n;n=n.prev)for(r=n.vars;r;r=r.next)if(r.name==t)return 1}}(e,n)?"variable-2":t}}var c={state:null,column:null,marked:null,cc:null};function s(){for(var e=arguments.length-1;0<=e;e--)c.cc.push(arguments[e])}function p(){return s.apply(null,arguments),!0}function Z(e,t){for(var r=t;r;r=r.next)if(r.name==e)return 1}function a(e){var t=c.state;if(c.marked="def",W){if(t.context)if("var"==t.lexical.info&&t.context&&t.context.block){var r=function e(t,r){{var n;return r?r.block?(n=e(t,r.prev))?n==r.prev?r:new te(n,r.vars,!0):null:Z(t,r.vars)?r:new te(r.prev,new re(t,r.vars),!1):null}}(e,t.context);if(null!=r)return void(t.context=r)}else if(!Z(e,t.localVars))return void(t.localVars=new re(e,t.localVars));l.globalVars&&!Z(e,t.globalVars)&&(t.globalVars=new re(e,t.globalVars))}}function ee(e){return"public"==e||"private"==e||"protected"==e||"abstract"==e||"readonly"==e}function te(e,t,r){this.prev=e,this.vars=t,this.block=r}function re(e,t){this.name=e,this.next=t}var ne=new re("this",new re("arguments",null));function m(){c.state.context=new te(c.state.context,c.state.localVars,!1),c.state.localVars=ne}function ae(){c.state.context=new te(c.state.context,c.state.localVars,!0),c.state.localVars=null}function k(){c.state.localVars=c.state.context.vars,c.state.context=c.state.context.prev}function v(n,a){function e(){var e=c.state,t=e.indented;if("stat"==e.lexical.type)t=e.lexical.indented;else for(var r=e.lexical;r&&")"==r.type&&r.align;r=r.prev)t=r.indented;e.lexical=new X(t,c.stream.column(),n,null,e.lexical,a)}return e.lex=!0,e}function y(){var e=c.state;e.lexical.prev&&(")"==e.lexical.type&&(e.indented=e.lexical.indented),e.lexical=e.lexical.prev)}function w(r){return function e(t){return t==r?p():";"==r||"}"==t||")"==t||"]"==t?s():p(e)}}function b(e,t){return"var"==e?p(v("vardef",t),qe,w(";"),y):"keyword a"==e?p(v("form"),oe,b,y):"keyword b"==e?p(v("form"),b,y):"keyword d"==e?c.stream.match(/^\s*$/,!1)?p():p(v("stat"),g,w(";"),y):"debugger"==e?p(w(";")):"{"==e?p(v("}"),ae,be,y,k):";"==e?p():"if"==e?("else"==c.state.lexical.info&&c.state.cc[c.state.cc.length-1]==y&&c.state.cc.pop()(),p(v("form"),oe,b,y,Oe)):"function"==e?p(q):"for"==e?p(v("form"),ae,Pe,b,k,y):"class"==e||u&&"interface"==t?(c.marked="keyword",p(v("form","class"==e?e:t),Fe,y)):"variable"==e?u&&"declare"==t?(c.marked="keyword",p(b)):u&&("module"==t||"enum"==t||"type"==t)&&c.stream.match(/^\s*\w/,!1)?(c.marked="keyword","enum"==t?p(Ze):"type"==t?p(We,w("operator"),z,w(";")):p(v("form"),T,w("{"),v("}"),be,y,y)):u&&"namespace"==t?(c.marked="keyword",p(v("form"),x,b,y)):u&&"abstract"==t?(c.marked="keyword",p(b)):p(v("stat"),me):"switch"==e?p(v("form"),oe,w("{"),v("}","switch"),ae,be,y,y,k):"case"==e?p(x,w(":")):"default"==e?p(w(":")):"catch"==e?p(v("form"),m,ie,b,y,k):"export"==e?p(v("stat"),Ge,y):"import"==e?p(v("stat"),Ke,y):"async"==e?p(b):"@"==t?p(x,b):s(v("stat"),x,w(";"),y)}function ie(e){if("("==e)return p(S,w(")"))}function x(e,t){return ce(e,t,!1)}function h(e,t){return ce(e,t,!0)}function oe(e){return"("!=e?s():p(v(")"),g,w(")"),y)}function ce(e,t,r){if(c.state.fatArrowAt==c.stream.start){var n=r?fe:le;if("("==e)return p(m,v(")"),V(S,")"),y,w("=>"),n,k);if("variable"==e)return s(m,T,w("=>"),n,k)}var a,n=r?M:j;return R.hasOwnProperty(e)?p(n):"function"==e?p(q,n):"class"==e||u&&"interface"==t?(c.marked="keyword",p(v("form"),Be,y)):"keyword c"==e||"async"==e?p(r?h:x):"("==e?p(v(")"),g,w(")"),y,n):"operator"==e||"spread"==e?p(r?h:x):"["==e?p(v("]"),Ye,y,n):"{"==e?we(ve,"}",null,n):"quasi"==e?s(se,n):"new"==e?p((a=r,function(e){return"."==e?p(a?pe:de):"variable"==e&&u?p(Ie,a?M:j):s(a?h:x)})):p()}function g(e){return e.match(/[;\}\)\],]/)?s():s(x)}function j(e,t){return","==e?p(g):M(e,t,!1)}function M(e,t,r){var n=0==r?j:M,a=0==r?x:h;return"=>"==e?p(m,r?fe:le,k):"operator"==e?/\+\+|--/.test(t)||u&&"!"==t?p(n):u&&"<"==t&&c.stream.match(/^([^<>]|<[^<>]*>)*>\s*\(/,!1)?p(v(">"),V(z,">"),y,n):"?"==t?p(x,w(":"),a):p(a):"quasi"==e?s(se,n):";"!=e?"("==e?we(h,")","call",n):"."==e?p(ke,n):"["==e?p(v("]"),g,w("]"),y,n):u&&"as"==t?(c.marked="keyword",p(z,n)):"regexp"==e?(c.state.lastType=c.marked="operator",c.stream.backUp(c.stream.pos-c.stream.start-1),p(a)):void 0:void 0}function se(e,t){return"quasi"!=e?s():"${"!=t.slice(t.length-2)?p(se):p(g,ue)}function ue(e){if("}"==e)return c.marked="string-2",c.state.tokenize=L,p(se)}function le(e){return Q(c.stream,c.state),s("{"==e?b:x)}function fe(e){return Q(c.stream,c.state),s("{"==e?b:h)}function de(e,t){if("target"==t)return c.marked="keyword",p(j)}function pe(e,t){if("target"==t)return c.marked="keyword",p(M)}function me(e){return":"==e?p(y,b):s(j,w(";"),y)}function ke(e){if("variable"==e)return c.marked="property",p()}function ve(e,t){return"async"==e?(c.marked="property",p(ve)):"variable"!=e&&"keyword"!=c.style?"number"==e||"string"==e?(c.marked=U?"property":c.style+" property",p(A)):"jsonld-keyword"==e?p(A):u&&ee(t)?(c.marked="keyword",p(ve)):"["==e?p(x,E,w("]"),A):"spread"==e?p(h,A):"*"==t?(c.marked="keyword",p(ve)):":"==e?s(A):void 0:(c.marked="property","get"==t||"set"==t?p(ye):(u&&c.state.fatArrowAt==c.stream.start&&(e=c.stream.match(/^\s*:\s*/,!1))&&(c.state.fatArrowAt=c.stream.pos+e[0].length),p(A)))}function ye(e){return"variable"!=e?s(A):(c.marked="property",p(q))}function A(e){return":"==e?p(h):"("==e?s(q):void 0}function V(n,a,i){function o(e,t){var r;return(i?-1"),z):"quasi"==e?s(Ve,I):void 0}function je(e){if("=>"==e)return p(z)}function Me(e){return e.match(/[\}\)\]]/)?p():","==e||";"==e?p(Me):s(Ae,Me)}function Ae(e,t){return"variable"==e||"keyword"==c.style?(c.marked="property",p(Ae)):"?"==t||"number"==e||"string"==e?p(Ae):":"==e?p(z):"["==e?p(w("variable"),xe,w("]"),Ae):"("==e?s(C,Ae):e.match(/[;\}\)\],]/)?void 0:p()}function Ve(e,t){return"quasi"!=e?s():"${"!=t.slice(t.length-2)?p(Ve):p(z,Ee)}function Ee(e){if("}"==e)return c.marked="string-2",c.state.tokenize=L,p(Ve)}function ze(e,t){return"variable"==e&&c.stream.match(/^\s*[?:]/,!1)||"?"==t?p(ze):":"==e?p(z):"spread"==e?p(ze):s(z)}function I(e,t){return"<"==t?p(v(">"),V(z,">"),y,I):"|"==t||"."==e||"&"==t?p(z):"["==e?p(z,w("]"),I):"extends"==t||"implements"==t?(c.marked="keyword",p(z)):"?"==t?p(z,w(":"),z):void 0}function Ie(e,t){if("<"==t)return p(v(">"),V(z,">"),y,I)}function Te(){return s(z,$e)}function $e(e,t){if("="==t)return p(z)}function qe(e,t){return"enum"==t?(c.marked="keyword",p(Ze)):s(T,E,$,_e)}function T(e,t){return u&&ee(t)?(c.marked="keyword",p(T)):"variable"==e?(a(t),p()):"spread"==e?p(T):"["==e?we(Se,"]"):"{"==e?we(Ce,"}"):void 0}function Ce(e,t){return"variable"!=e||c.stream.match(/^\s*:/,!1)?("variable"==e&&(c.marked="property"),"spread"==e?p(T):"}"==e?s():"["==e?p(x,w("]"),w(":"),Ce):p(w(":"),T,$)):(a(t),p($))}function Se(){return s(T,$)}function $(e,t){if("="==t)return p(h)}function _e(e){if(","==e)return p(qe)}function Oe(e,t){if("keyword b"==e&&"else"==t)return p(v("form","else"),b,y)}function Pe(e,t){return"await"==t?p(Pe):"("==e?p(v(")"),Ne,y):void 0}function Ne(e){return"var"==e?p(qe,Ue):("variable"==e?p:s)(Ue)}function Ue(e,t){return")"==e?p():";"==e?p(Ue):"in"==t||"of"==t?(c.marked="keyword",p(x,Ue)):s(x,Ue)}function q(e,t){return"*"==t?(c.marked="keyword",p(q)):"variable"==e?(a(t),p(q)):"("==e?p(m,v(")"),V(S,")"),y,he,b,k):u&&"<"==t?p(v(">"),V(Te,">"),y,q):void 0}function C(e,t){return"*"==t?(c.marked="keyword",p(C)):"variable"==e?(a(t),p(C)):"("==e?p(m,v(")"),V(S,")"),y,he,k):u&&"<"==t?p(v(">"),V(Te,">"),y,C):void 0}function We(e,t){return"keyword"==e||"variable"==e?(c.marked="type",p(We)):"<"==t?p(v(">"),V(Te,">"),y):void 0}function S(e,t){return"@"==t&&p(x,S),"spread"==e?p(S):u&&ee(t)?(c.marked="keyword",p(S)):u&&"this"==e?p(E,$):s(T,E,$)}function Be(e,t){return("variable"==e?Fe:He)(e,t)}function Fe(e,t){if("variable"==e)return a(t),p(He)}function He(e,t){return"<"==t?p(v(">"),V(Te,">"),y,He):"extends"==t||"implements"==t||u&&","==e?("implements"==t&&(c.marked="keyword"),p(u?z:x,He)):"{"==e?p(v("}"),_,y):void 0}function _(e,t){return"async"==e||"variable"==e&&("static"==t||"get"==t||"set"==t||u&&ee(t))&&c.stream.match(/^\s+#?[\w$\xa1-\uffff]/,!1)?(c.marked="keyword",p(_)):"variable"==e||"keyword"==c.style?(c.marked="property",p(De,_)):"number"==e||"string"==e?p(De,_):"["==e?p(x,E,w("]"),De,_):"*"==t?(c.marked="keyword",p(_)):u&&"("==e?s(C,_):";"==e||","==e?p(_):"}"==e?p():"@"==t?p(x,_):void 0}function De(e,t){if("!"==t)return p(De);if("?"==t)return p(De);if(":"==e)return p(z,$);if("="==t)return p(h);e=c.state.lexical.prev;return s(e&&"interface"==e.info?C:q)}function Ge(e,t){return"*"==t?(c.marked="keyword",p(Xe,w(";"))):"default"==t?(c.marked="keyword",p(x,w(";"))):"{"==e?p(V(Je,"}"),Xe,w(";")):s(b)}function Je(e,t){return"as"==t?(c.marked="keyword",p(w("variable"))):"variable"==e?s(h,Je):void 0}function Ke(e){return"string"==e?p():"("==e?s(x):"."==e?s(j):s(Le,Qe,Xe)}function Le(e,t){return"{"==e?we(Le,"}"):("variable"==e&&a(t),"*"==t&&(c.marked="keyword"),p(Re))}function Qe(e){if(","==e)return p(Le,Qe)}function Re(e,t){if("as"==t)return c.marked="keyword",p(Le)}function Xe(e,t){if("from"==t)return c.marked="keyword",p(x)}function Ye(e){return"]"==e?p():s(V(h,"]"))}function Ze(){return s(v("form"),T,w("{"),v("}"),V(et,"}"),y,y)}function et(){return s(T,$)}function tt(e,t,r){return t.tokenize==d&&/^(?:operator|sof|keyword [bcd]|case|new|export|default|spread|[\[{}\(,;:]|=>)$/.test(t.lastType)||"quasi"==t.lastType&&/\{\s*$/.test(e.string.slice(0,e.pos-(r||0)))}return m.lex=ae.lex=!0,y.lex=k.lex=!0,{startState:function(e){e={tokenize:d,lastType:"sof",cc:[],lexical:new X((e||0)-f,0,"block",!1),localVars:l.localVars,context:l.localVars&&new te(null,null,!1),indented:e||0};return l.globalVars&&"object"==typeof l.globalVars&&(e.globalVars=l.globalVars),e},token:function(e,t){if(e.sol()&&(t.lexical.hasOwnProperty("align")||(t.lexical.align=!1),t.indented=e.indentation(),Q(e,t)),t.tokenize!=K&&e.eatSpace())return null;var r=t.tokenize(e,t);return"comment"==H?r:(t.lastType="operator"!=H||"++"!=D&&"--"!=D?H:"incdec",Y(t,r,H,D,e))},indent:function(e,t){if(e.tokenize==K||e.tokenize==L)return rt.Pass;if(e.tokenize!=d)return 0;var r,n=t&&t.charAt(0),a=e.lexical;if(!/^\s*else\b/.test(t))for(var i=e.cc.length-1;0<=i;--i){var o=e.cc[i];if(o==y)a=a.prev;else if(o!=Oe&&o!=k)break}for(;("stat"==a.type||"form"==a.type)&&("}"==n||(r=e.cc[e.cc.length-1])&&(r==j||r==M)&&!/^[,\.=+\-*:?[\(]/.test(t));)a=a.prev;var c,s=(a=N&&")"==a.type&&"stat"==a.prev.type?a.prev:a).type,u=n==s;return"vardef"==s?a.indented+("operator"==e.lastType||","==e.lastType?a.info.length+1:0):"form"==s&&"{"==n?a.indented:"form"==s?a.indented+f:"stat"==s?a.indented+(s=t,"operator"==(c=e).lastType||","==c.lastType||G.test(s.charAt(0))||/[,.]/.test(s.charAt(0))?N||f:0):"switch"!=a.info||u||0==l.doubleIndentSwitch?a.align?a.column+(u?0:1):a.indented+(u?0:f):a.indented+(/^(?:case|default)\b/.test(t)?f:2*f)},electricInput:/^\s*(?:case .*?:|default:|\{|\})$/,blockCommentStart:o?null:"/*",blockCommentEnd:o?null:"*/",blockCommentContinue:o?null:" * ",lineComment:o?null:"//",fold:"brace",closeBrackets:"()[]{}''\"\"``",helperType:o?"json":"javascript",jsonldMode:U,jsonMode:o,expressionAllowed:tt,skipExpression:function(e){Y(e,"atom","atom","true",new rt.StringStream("",2,null))}}}),rt.registerHelper("wordChars","javascript",/[\w$]/),rt.defineMIME("text/javascript","javascript"),rt.defineMIME("text/ecmascript","javascript"),rt.defineMIME("application/javascript","javascript"),rt.defineMIME("application/x-javascript","javascript"),rt.defineMIME("application/ecmascript","javascript"),rt.defineMIME("application/json",{name:"javascript",json:!0}),rt.defineMIME("application/x-json",{name:"javascript",json:!0}),rt.defineMIME("application/manifest+json",{name:"javascript",json:!0}),rt.defineMIME("application/ld+json",{name:"javascript",jsonld:!0}),rt.defineMIME("text/typescript",{name:"javascript",typescript:!0}),rt.defineMIME("application/typescript",{name:"javascript",typescript:!0})});; +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor,a=(null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className)),y(n)),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0",triples:"",explode:"[]{}"},k=S.Pos;function y(e,t){return"pairs"==t&&"string"==typeof e?e:("object"==typeof e&&null!=e[t]?e:n)[t]}S.defineOption("autoCloseBrackets",!1,function(e,t,n){n&&n!=S.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),t&&(r(y(t,"pairs")),e.state.closeBrackets=t,e.addKeyMap(i))});var i={Backspace:function(e){var t=O(e);if(!t||e.getOption("disableInput"))return S.Pass;for(var n=y(t,"pairs"),r=e.listSelections(),i=0;it.firstLine();)i=c.Pos(i.line-1,0),u=o(!1);u&&!u.cleared&&"unfold"!==f&&(e=function(n,o,e){n=a(n,o,"widget");"function"==typeof n&&(n=n(e.from,e.to));"string"==typeof n?(o=document.createTextNode(n),(n=document.createElement("span")).appendChild(o),n.className="CodeMirror-foldmarker"):n=n&&n.cloneNode(!0);return n}(t,n,u),c.on(e,"mousedown",function(n){r.clear(),c.e_preventDefault(n)}),(r=t.markText(u.from,u.to,{replacedWith:e,clearOnEnter:a(t,n,"clearOnEnter"),__isFold:!0})).on("clear",function(n,o){c.signal(t,"unfold",t,n,o)}),c.signal(t,"fold",t,u.from,u.to))}c.newFoldFunction=function(e,r){return function(n,o){t(n,o,{rangeFinder:e,widget:r})}},c.defineExtension("foldCode",function(n,o,e){t(this,n,o,e)}),c.defineExtension("isFolded",function(n){for(var o=this.findMarksAt(n),e=0;e=d){if(l&&e&&l.test(e.className))return;o=p(i.indicatorOpen)}}(o||e)&&r.setGutterMarker(t,i.gutter,o)})}function e(t){return new RegExp("(^|\\s)"+t+"(?:$|\\s)\\s*")}function f(t){var o=t.getViewport(),e=t.state.foldGutter;e&&(t.operation(function(){i(t,o.from,o.to)}),e.from=o.from,e.to=o.to)}function d(t,o,e){var n=t.state.foldGutter;!n||e==(e=n.options).gutter&&((n=s(t,o))?n.clear():t.foldCode(c(o,0),e))}function a(t,o){"mode"==o&&u(t)}function u(t){var o,e=t.state.foldGutter;e&&(o=e.options,e.from=e.to=0,clearTimeout(e.changeUpdate),e.changeUpdate=setTimeout(function(){f(t)},o.foldOnChangeTimeSpan||600))}function l(o){var t,e=o.state.foldGutter;e&&(t=e.options,clearTimeout(e.changeUpdate),e.changeUpdate=setTimeout(function(){var t=o.getViewport();e.from==e.to||20e.to&&(i(o,e.to,t.to),e.to=t.to)})},t.updateViewportTimeSpan||400))}function m(t,o){var e=t.state.foldGutter;!e||(o=o.line)>=e.from&&oo.lastLine())return null;var r=o.getTokenAt(g.Pos(e,1));if("keyword"!=(r=/\S/.test(r.string)?r:o.getTokenAt(g.Pos(e,r.end+1))).type||"import"!=r.string)return null;for(var n=e,t=Math.min(o.lastLine(),e+10);n<=t;++n){var i=o.getLine(n).indexOf(";");if(-1!=i)return{startCh:r.end,end:g.Pos(n,i)}}}var n,e=e.line,t=r(e);if(!t||r(e-1)||(n=r(e-2))&&n.end.line==e-1)return null;for(var i=t.end;;){var l=r(i.line+1);if(null==l)break;i=l.end}return{from:o.clipPos(g.Pos(e,t.startCh+1)),to:i}}),g.registerHelper("fold","include",function(n,e){function r(e){if(en.lastLine())return null;var r=n.getTokenAt(g.Pos(e,1));return"meta"==(r=/\S/.test(r.string)?r:n.getTokenAt(g.Pos(e,r.end+1))).type&&"#include"==r.string.slice(0,8)?r.start+8:void 0}var e=e.line,t=r(e);if(null==t||null!=r(e-1))return null;for(var i=e;;){if(null==r(i+1))break;++i}return{from:g.Pos(e,t+1),to:n.clipPos(g.Pos(i))}})});; +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(T){"use strict";var F="CodeMirror-hint-active";function n(t,e){var i;this.cm=t,this.options=e,this.widget=null,this.debounce=0,this.tick=0,this.startPos=this.cm.getCursor("start"),this.startLen=this.cm.getLine(this.startPos.line).length-this.cm.getSelection().length,this.options.updateOnCursorActivity&&t.on("cursorActivity",(i=this).activityFunc=function(){i.cursorActivity()})}T.showHint=function(t,e,i){if(!e)return t.showHint(i);i&&i.async&&(e.async=!0);var n={hint:e};if(i)for(var o in i)n[o]=i[o];return t.showHint(n)},T.defineExtension("showHint",function(t){t=function(t,e,i){var n=t.options.hintOptions,o={};for(s in c)o[s]=c[s];if(n)for(var s in n)void 0!==n[s]&&(o[s]=n[s]);if(i)for(var s in i)void 0!==i[s]&&(o[s]=i[s]);o.hint.resolve&&(o.hint=o.hint.resolve(t,e));return o}(this,this.getCursor("start"),t);var e=this.listSelections();if(!(1l.clientHeight+1;setTimeout(function(){f=s.getScrollInfo()});0b&&(l.style.width=b-5+"px",k-=H.right-H.left-b),l.style.left=(m=Math.max(p.left-k-y,0))+"px"),e)for(var x=l.firstChild;x;x=x.nextSibling)x.style.paddingRight=s.display.nativeBarWidth+"px";s.addKeyMap(this.keyMap=function(t,n){var o={Up:function(){n.moveFocus(-1)},Down:function(){n.moveFocus(1)},PageUp:function(){n.moveFocus(1-n.menuSize(),!0)},PageDown:function(){n.moveFocus(n.menuSize()-1,!0)},Home:function(){n.setFocus(0)},End:function(){n.setFocus(n.length-1)},Enter:n.pick,Tab:n.pick,Esc:n.close},e=(/Mac/.test(navigator.platform)&&(o["Ctrl-P"]=function(){n.moveFocus(-1)},o["Ctrl-N"]=function(){n.moveFocus(1)}),t.options.customKeys),s=e?{}:o;function i(t,e){var i="string"!=typeof e?function(t){return e(t,n)}:o.hasOwnProperty(e)?o[e]:e;s[t]=i}if(e)for(var c in e)e.hasOwnProperty(c)&&i(c,e[c]);var r=t.options.extraKeys;if(r)for(var c in r)r.hasOwnProperty(c)&&i(c,r[c]);return s}(o,{moveFocus:function(t,e){i.changeActive(i.selectedHint+t,e)},setFocus:function(t){i.changeActive(t)},menuSize:function(){return i.screenAmount()},length:n.length,close:function(){o.close()},pick:function(){i.pick()},data:t})),o.options.closeOnUnfocus&&(s.on("blur",this.onBlur=function(){C=setTimeout(function(){o.close()},100)}),s.on("focus",this.onFocus=function(){clearTimeout(C)})),s.on("scroll",this.onScroll=function(){var t=s.getScrollInfo(),e=s.getWrapperElement().getBoundingClientRect(),i=(f=f||s.getScrollInfo(),g+f.top-t.top),n=i-(r.pageYOffset||(c.documentElement||c.body).scrollTop);if(v||(n+=l.offsetHeight),n<=e.top||n>=e.bottom)return o.close();l.style.top=i+"px",l.style.left=m+f.left-t.left+"px"}),T.on(l,"dblclick",function(t){t=O(l,t.target||t.srcElement);t&&null!=t.hintId&&(i.changeActive(t.hintId),i.pick())}),T.on(l,"click",function(t){t=O(l,t.target||t.srcElement);t&&null!=t.hintId&&(i.changeActive(t.hintId),o.options.completeOnSingleClick&&i.pick())}),T.on(l,"mousedown",function(){setTimeout(function(){s.focus()},20)});var S=this.getSelectedHintRange();return 0===S.from&&0===S.to||this.scrollToActive(),T.signal(t,"select",n[this.selectedHint],l.childNodes[this.selectedHint]),!0}function r(t,e,i,n){t.async?t(e,n,i):(t=t(e,i))&&t.then?t.then(n):n(t)}n.prototype={close:function(){this.active()&&(this.cm.state.completionActive=null,this.tick=null,this.options.updateOnCursorActivity&&this.cm.off("cursorActivity",this.activityFunc),this.widget&&this.data&&T.signal(this.data,"close"),this.widget&&this.widget.close(),T.signal(this.cm,"endCompletion",this.cm))},active:function(){return this.cm.state.completionActive==this},pick:function(t,e){var i=t.list[e],n=this;this.cm.operation(function(){i.hint?i.hint(n.cm,t,i):n.cm.replaceRange(M(i),i.from||t.from,i.to||t.to,"complete"),T.signal(t,"pick",i),n.cm.scrollIntoView()}),this.options.closeOnPick&&this.close()},cursorActivity:function(){this.debounce&&(s(this.debounce),this.debounce=0);var t,e=this.startPos,i=(this.data&&(e=this.data.from),this.cm.getCursor()),n=this.cm.getLine(i.line);i.line!=this.startPos.line||n.length-i.ch!=this.startLen-this.startPos.ch||i.ch=this.data.list.length?t=e?this.data.list.length-1:0:t<0&&(t=e?0:this.data.list.length-1),this.selectedHint!=t&&((e=this.hints.childNodes[this.selectedHint])&&(e.className=e.className.replace(" "+F,""),e.removeAttribute("aria-selected")),(e=this.hints.childNodes[this.selectedHint=t]).className+=" "+F,e.setAttribute("aria-selected","true"),this.completion.cm.getInputField().setAttribute("aria-activedescendant",e.id),this.scrollToActive(),T.signal(this.data,"select",this.data.list[this.selectedHint],e))},scrollToActive:function(){var t=this.getSelectedHintRange(),e=this.hints.childNodes[t.from],t=this.hints.childNodes[t.to],i=this.hints.firstChild;e.offsetTopthis.hints.scrollTop+this.hints.clientHeight&&(this.hints.scrollTop=t.offsetTop+t.offsetHeight-this.hints.clientHeight+i.offsetTop)},screenAmount:function(){return Math.floor(this.hints.clientHeight/this.hints.firstChild.offsetHeight)||1},getSelectedHintRange:function(){var t=this.completion.options.scrollMargin||0;return{from:Math.max(0,this.selectedHint-t),to:Math.min(this.data.list.length-1,this.selectedHint+t)}}},T.registerHelper("hint","auto",{resolve:function(t,e){var i,c=t.getHelpers(e,"hint");return c.length?((e=function(t,n,o){var s=function(t,e){if(!t.somethingSelected())return e;for(var i=[],n=0;n,]/,closeOnPick:!0,closeOnUnfocus:!0,updateOnCursorActivity:!0,completeOnSingleClick:!0,container:null,customKeys:null,extraKeys:null,paddingForScrollbar:!0,moveOnOverlap:!0};T.defineOption("hintOptions",null)});; +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(p){"use strict";var h="CodeMirror-lint-markers",g="CodeMirror-lint-line-";function u(t){t.parentNode&&t.parentNode.removeChild(t)}function v(t,e,n,o){t=t,e=e,n=n,(i=document.createElement("div")).className="CodeMirror-lint-tooltip cm-s-"+t.options.theme,i.appendChild(n.cloneNode(!0)),(t.state.lint.options.selfContain?t.getWrapperElement():document.body).appendChild(i),p.on(document,"mousemove",a),a(e),null!=i.style.opacity&&(i.style.opacity=1);var i,r=i;function a(t){if(!i.parentNode)return p.off(document,"mousemove",a);var e=Math.max(0,t.clientY-i.offsetHeight-5),t=Math.max(0,Math.min(t.clientX+5,i.ownerDocument.defaultView.innerWidth-i.offsetWidth));i.style.top=e+"px",i.style.left=t+"px"}function l(){var t;p.off(o,"mouseout",l),r&&((t=r).parentNode&&(null==t.style.opacity&&u(t),t.style.opacity=0,setTimeout(function(){u(t)},600)),r=null)}var s=setInterval(function(){if(r)for(var t=o;;t=t.parentNode){if((t=t&&11==t.nodeType?t.host:t)==document.body)return;if(!t){l();break}}if(!r)return clearInterval(s)},400);p.on(o,"mouseout",l)}function a(s,t,e){for(var n in this.marked=[],(t=t instanceof Function?{getAnnotations:t}:t)&&!0!==t||(t={}),this.options={},this.linterOptions=t.options||{},o)this.options[n]=o[n];for(var n in t)o.hasOwnProperty(n)?null!=t[n]&&(this.options[n]=t[n]):t.options||(this.linterOptions[n]=t[n]);this.timeout=null,this.hasGutter=e,this.onMouseOver=function(t){var e=s,n=t.target||t.srcElement;if(/\bCodeMirror-lint-mark-/.test(n.className)){for(var n=n.getBoundingClientRect(),o=(n.left+n.right)/2,n=(n.top+n.bottom)/2,i=e.findMarksAt(e.coordsChar({left:o,top:n},"client")),r=[],a=0;at.length-n)break;(!i||h>i.index+i[0].length)&&(i=o),r=o.index+1}return i}function O(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.firstLine();o<=i;i--,r=-1){var h=t.getLine(i),h=L(h,e,r<0?0:h.length-r);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function h(t,e,n){if(!d(e))return O(t,e,n);e=m(e,"gm");for(var i=1,r=t.getLine(n.line).length-n.ch,o=n.line,h=t.firstLine();h<=o;){for(var l=0;l>1,l=i(t.slice(0,h)).length;if(l==n)return h;n(this.doc.getLine(e.line)||"").length&&(e.ch=0,e.line++)),0!=r.cmpPos(e,this.doc.clipPos(e))))return this.atOccurrence=!1;var e=this.matches(t,e);return this.afterEmptyMatch=e&&0==r.cmpPos(e.from,e.to),e?(this.pos=e,this.atOccurrence=!0,this.pos.match||!0):(e=x(t?this.doc.firstLine():this.doc.lastLine()+1,0),this.pos={from:e,to:e},this.atOccurrence=!1)},from:function(){if(this.atOccurrence)return this.pos.from},to:function(){if(this.atOccurrence)return this.pos.to},replace:function(t,e){this.atOccurrence&&(t=r.splitLines(t),this.doc.replaceRange(t,this.pos.from,this.pos.to,e),this.pos.to=x(this.pos.from.line+t.length-1,t[t.length-1].length+(1==t.length?this.pos.from.ch:0)))}},r.defineExtension("getSearchCursor",function(t,e,n){return new i(this.doc,t,e,n)}),r.defineDocExtension("getSearchCursor",function(t,e,n){return new i(this,t,e,n)}),r.defineExtension("selectMatches",function(t,e){for(var n=[],i=this.getSearchCursor(t,this.getCursor("from"),e);i.findNext()&&!(0n.cursorCoords(r,"window").top&&((a=o).style.opacity=.4)}))},e=C(s=n),c=u,l=function(e,r){var o=f.keyName(e),t=n.getOption("extraKeys"),t=t&&t[o]||f.keyMap[n.getOption("keyMap")][o];"findNext"==t||"findPrev"==t||"findPersistentNext"==t||"findPersistentPrev"==t?(f.e_stop(e),y(n,p(n),r),n.execCommand(t)):"find"!=t&&"findPersistent"!=t||(f.e_stop(e),i(r,e))},s.openDialog(e,i,{value:c,selectValueOnOpen:!0,closeOnEnter:!1,onClose:function(){v(s)},onKeyDown:l,bottom:s.options.search.bottom}),o&&u&&(y(n,t,u),g(n,r))):m(n,C(n),"Search for:",u,function(e){e&&!t.query&&n.operation(function(){y(n,t,e),t.posFrom=t.posTo=n.getCursor(),g(n,r)})})}function g(o,t,n){o.operation(function(){var e=p(o),r=d(o,e.query,t?e.posFrom:e.posTo);(r.find(t)||(r=d(o,e.query,t?f.Pos(o.lastLine()):f.Pos(o.firstLine(),0))).find(t))&&(o.setSelection(r.from(),r.to()),o.scrollIntoView({from:r.from(),to:r.to()},20),e.posFrom=r.from(),e.posTo=r.to(),n&&n(r.from(),r.to()))})}function v(r){r.operation(function(){var e=p(r);e.lastQuery=e.query,e.query&&(e.query=e.queryText=null,r.removeOverlay(e.overlay),e.annotate&&(e.annotate.clear(),e.annotate=null))})}function x(e,r){var o,t=e?document.createElement(e):document.createDocumentFragment();for(o in r)t[o]=r[o];for(var n=2;n '+i.phrase("(Use line:column or scroll% syntax)")+"",o=t.phrase("Jump to line:"),r=n.line+1+":"+n.ch,s=function(e){var o,r;e&&((o=/^\s*([\+\-]?\d+)\s*\:\s*(\d+)\s*$/.exec(e))?t.setCursor(l(t,o[1]),Number(o[2])):(o=/^\s*([\+\-]?\d+(\.\d+)?)\%\s*/.exec(e))?(r=Math.round(t.lineCount()*Number(o[1])/100),/^[-+]/.test(o[1])&&(r=n.line+r+1),t.setCursor(r-1,n.ch)):(o=/^\s*\:?\s*([\+\-]?\d+)\s*/.exec(e))&&t.setCursor(l(t,o[1]),n.ch))},e.openDialog?e.openDialog(i,s,{value:r,selectValueOnOpen:!0,bottom:e.options.search.bottom}):s(prompt(o,r))},e.keyMap.default["Alt-G"]="jumpToLine"});; +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(t){"use strict";function e(t,e){function i(t){clearTimeout(n.doRedraw),n.doRedraw=setTimeout(function(){n.redraw()},t)}this.cm=t,this.options=e,this.buttonHeight=e.scrollButtonHeight||t.getOption("scrollButtonHeight"),this.annotations=[],this.doRedraw=this.doUpdate=null,this.div=t.getWrapperElement().appendChild(document.createElement("div")),this.div.style.cssText="position: absolute; right: 0; top: 0; z-index: 7; pointer-events: none",this.computeScale();var n=this;t.on("refresh",this.resizeHandler=function(){clearTimeout(n.doUpdate),n.doUpdate=setTimeout(function(){n.computeScale()&&i(20)},100)}),t.on("markerAdded",this.resizeHandler),t.on("markerCleared",this.resizeHandler),!1!==e.listenForChanges&&t.on("changes",this.changeHandler=function(){i(250)})}t.defineExtension("annotateScrollbar",function(t){return new e(this,t="string"==typeof t?{className:t}:t)}),t.defineOption("scrollButtonHeight",0),e.prototype.computeScale=function(){var t=this.cm,t=(t.getWrapperElement().clientHeight-t.display.barHeight-2*this.buttonHeight)/t.getScrollerElement().scrollHeight;if(t!=this.hScale)return this.hScale=t,!0},e.prototype.update=function(t){this.annotations=t,this.redraw()},e.prototype.redraw=function(t){!1!==t&&this.computeScale();var n=this.cm,e=this.hScale,i=document.createDocumentFragment(),o=this.annotations,r=n.getOption("lineWrapping"),a=r&&1.5*n.defaultTextHeight(),s=null,h=null;function l(t,e){var i;return s!=t.line&&(s=t.line,h=n.getLineHandle(t.line),(i=n.getLineHandleVisualStart(h))!=h&&(s=n.getLineNumber(i),h=i)),h.widgets&&h.widgets.length||r&&h.height>a?n.charCoords(t,"local")[e?"top":"bottom"]:n.heightAtLine(h,"local")+(e?0:h.height)}var d=n.lastLine();if(n.display.barWidth)for(var c,p=0;pd)){for(var m,f,g=c||l(u.from,!0)*e,H=l(u.to,!1)*e;pd)&&!(H+.9<(c=l(o[p+1].from,!0)*e));)H=l((u=o[++p]).to,!1)*e;H!=g&&(m=Math.max(H-g,3),(f=i.appendChild(document.createElement("div"))).style.cssText="position: absolute; right: 0px; width: "+Math.max(n.display.barWidth-1,2)+"px; top: "+(g+this.buttonHeight)+"px; height: "+m+"px",f.className=this.options.className,u.id&&f.setAttribute("annotation-id",u.id))}}this.div.textContent="",this.div.appendChild(i)},e.prototype.clear=function(){this.cm.off("refresh",this.resizeHandler),this.cm.off("markerAdded",this.resizeHandler),this.cm.off("markerCleared",this.resizeHandler),this.changeHandler&&this.cm.off("changes",this.changeHandler),this.div.parentNode.removeChild(this.div)}});; +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("./searchcursor"),require("../scroll/annotatescrollbar")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","./searchcursor","../scroll/annotatescrollbar"],t):t(CodeMirror)}(function(h){"use strict";function o(t,e,i,o){this.cm=t;var n,s={listenForChanges:!1};for(n in this.options=o)s[n]=o[n];s.className||(s.className="CodeMirror-search-match"),this.annotation=t.annotateScrollbar(s),this.query=e,this.caseFold=i,this.gap={from:t.firstLine(),to:t.lastLine()+1},this.matches=[],this.update=null,this.findMatches(),this.annotation.update(this.matches);var a=this;t.on("change",this.changeHandler=function(t,e){a.onChange(e)})}h.defineExtension("showMatchesOnScrollbar",function(t,e,i){return new o(this,t,e,i=(i="string"==typeof i?{className:i}:i)||{})});function c(t,e,i){return t<=e?t:Math.max(e,t+i)}o.prototype.findMatches=function(){if(this.gap){for(var t=0;t=this.gap.to)break;e.to.line>=this.gap.from&&this.matches.splice(t--,1)}for(var e,i=this.cm.getSearchCursor(this.query,h.Pos(this.gap.from,0),{caseFold:this.caseFold,multiline:this.options.multiline}),o=this.options&&this.options.maxMatches||1e3;i.findNext();){if((e={from:i.from(),to:i.to()}).from.line>=this.gap.to)break;if(this.matches.splice(t++,0,e),this.matches.length>o)break}this.gap=null}},o.prototype.onChange=function(t){var e=t.from.line,i=h.changeEnd(t).line,o=i-t.to.line;if(this.gap?(this.gap.from=Math.min(c(this.gap.from,e,o),t.from.line),this.gap.to=Math.max(c(this.gap.to,e,o),t.from.line)):this.gap={from:t.from.line,to:i+1},o)for(var n=0;n { while (i < n) { const c = text.charCodeAt(i); if (c === 32 || c === 9 || c === 10 || c === 13) i++; else break; } }; + const fail = (message, pos = i) => { throw new JSONError(message, pos); }; + function string() { + const start = i; i++; + let out = ""; + for (;;) { + if (i >= n) fail("This string has no closing \"", start); + const c = text[i]; + if (c === '"') { i++; return out; } + if (c === "\\") { + const e = text[i + 1]; + const simple = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + if (e in simple) { out += simple[e]; i += 2; continue; } + if (e === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) { out += String.fromCharCode(parseInt(text.slice(i + 2, i + 6), 16)); i += 6; continue; } + fail(`\\${e || ""} isn't a JSON escape: write \\\\ for a backslash`); + } + if (c === "\n") fail("A string can't go over two lines: write \\n for a line break", start); + if (c < " ") fail("A control character in a string: write \\t for a tab"); + out += c; i++; + } + } + function value(path) { + ws(); + if (i >= n) fail("The file ends where a value should be"); + const c = text[i]; + if (c === "{") return object(path); + if (c === "[") return array(path); + if (c === '"') return string(); + const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y; number.lastIndex = i; + const m = number.exec(text); + if (m && m[0].length && m[0] !== "-") { i += m[0].length; return Number(m[0]); } + for (const [word, v] of [["true", true], ["false", false], ["null", null]]) if (text.startsWith(word, i)) { i += word.length; return v; } + if (c === "'") fail("JSON strings use double quotes \", not '"); + if (text.startsWith("//", i) || text.startsWith("/*", i)) fail("JSON has no comments: use a \"comment\" key instead"); + if (/[A-Za-z_]/.test(c)) fail(`A word needs quotes in JSON: "${(text.slice(i).match(/^[\w-]+/) || [""])[0]}"`); + fail(`Expected a value, found ${JSON.stringify(c)}`); + } + function object(path) { + const result = {}, seen = new Set(); + i++; ws(); + if (text[i] === "}") { i++; return result; } + for (;;) { + ws(); + if (text[i] === "}") fail("A , before } isn't allowed in JSON: remove the last comma"); + if (text[i] === "'") fail("JSON keys use double quotes \", not '"); + if (text[i] !== '"') fail(i >= n ? "The file ends inside { }: a } is missing" : /[A-Za-z_]/.test(text[i]) ? "Keys need double quotes in JSON" : "Expected a \"key\""); + const keyFrom = i, key = string(), keyTo = i; + ws(); + if (text[i] !== ":") fail(`Expected : after "${key}"`); + i++; ws(); + const from = i, v = value([...path, key]); + nodes.push({ path: [...path, key], keyFrom, keyTo, from, to: i, value: v }); + if (seen.has(key)) duplicates.push({ path: [...path, key], keyFrom, keyTo }); + seen.add(key); + result[key] = v; + ws(); + if (text[i] === ",") { i++; continue; } + if (text[i] === "}") { i++; return result; } + fail(i >= n ? "The file ends inside { }: a } is missing" : text[i] === '"' ? "A , is missing before this key" : "Expected , or }"); + } + } + function array(path) { + const result = []; + i++; ws(); + if (text[i] === "]") { i++; return result; } + for (;;) { + ws(); + if (text[i] === "]") fail("A , before ] isn't allowed in JSON: remove the last comma"); + const from = i, v = value([...path, result.length]); + nodes.push({ path: [...path, result.length], keyFrom: null, keyTo: null, from, to: i, value: v }); + result.push(v); + ws(); + if (text[i] === ",") { i++; continue; } + if (text[i] === "]") { i++; return result; } + fail(i >= n ? "The file ends inside [ ]: a ] is missing" : "A , or ] is missing"); + } + } + ws(); + const result = value([]); + ws(); + if (i < n) fail("There's more after the end of the theme: a { or [ too few, or a } too many?"); + if (result === null || typeof result !== "object" || Array.isArray(result)) fail("theme.json has to be one { } object", 0); + return { value: result, nodes, duplicates }; +} + +/* ---------------------------------------------------------------- What a place in theme.json is */ +const bare = (key) => String(key).replace(/\[.*$/, ""); + +/* The kind of object at path (an array of keys): 'top', 'info', 'region', 'section', 'config', + 'pallet', 'scheme', 'schemes', 'elements', 'resources', 'override', 'translations' or 'other'. */ +function kindOf(path) { + if (!path.length) return "top"; + const [a, ...rest] = path.map(bare); + if (a === "#info") return rest.length ? "other" : "info"; + if (a === "#base") return rest.length ? "other" : "region"; + if (a === "#elements") return rest.length === 0 ? "elements" : rest.length === 1 ? "region" : "other"; + if (a === "#pallet") return rest.length ? "other" : "pallet"; + if (a === "#resources") return rest.length === 0 ? "resources" : "other"; + if (a === "#override") return rest.length ? "other" : "override"; + if (a === "#translations") return "translations"; + if (a === "#schemes") { + if (!rest.length) return "schemes"; + if (rest.length === 1) return "scheme"; + return kindOf(path.slice(2)); + } + if (a.startsWith("#")) return "other"; + if (!rest.length) return "section"; + if (rest.length === 1) return rest[0] === "#config" ? "config" : "region"; + return "other"; +} + +/* ---------------------------------------------------------------- Checks */ +function closest(word, words) { + let best = null, bestScore = 3; + for (const other of words) { + const d = distance(word, other); + if (d < bestScore || (d === bestScore && best === null && d <= Math.max(1, Math.floor(word.length / 4)))) { best = other; bestScore = d; } + } + return bestScore <= 2 ? best : null; +} +function distance(a, b) { + if (Math.abs(a.length - b.length) > 3) return 9; + const row = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + let prev = row[0]; row[0] = i; + for (let j = 1; j <= b.length; j++) { + const tmp = row[j]; + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = tmp; + } + } + return row[b.length]; +} +const isInt = (v) => typeof v === "number" && Number.isInteger(v); +const isNum = (v) => typeof v === "number"; +function colorProblem(v, colors) { + if (typeof v === "string") { + if (colors.includes(v)) return null; + if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v)) return null; + if (v.startsWith("#")) return "a color is #rrggbb, #rgb or #rrggbbaa"; + const near = closest(v, colors); + return `"${v}" isn't a color in this theme's #pallet` + (near ? `: did you mean "${near}"?` : ""); + } + if (Array.isArray(v) && (v.length === 3 || v.length === 4) && v.every((p) => isInt(p) && p >= 0 && p <= 255)) return null; + return "a color is a #pallet name, \"#rrggbb\" or [r, g, b] / [r, g, b, a] of 0-255"; +} +/* What's wrong with value v for region key `key`, or null. */ +function valueProblem(key, v, names) { + const spec = edit.schema.keys[key]; + if (!spec) return null; + const { type } = spec; + const range = (x) => (spec.min !== undefined && x < spec.min) || (spec.max !== undefined && x > spec.max) + ? `${key} goes from ${spec.min ?? "…"} to ${spec.max ?? "…"}` : null; + switch (type) { + case "int": return isInt(v) ? range(v) : `${key} is a whole number`; + case "float": return isNum(v) ? range(v) : `${key} is a number`; + case "bool": return typeof v === "boolean" ? null : `${key} is true or false`; + case "color": return colorProblem(v, names.colors); + case "rect": return Array.isArray(v) && v.length === 4 && v.every(isNum) ? null : "area is [x, y, width, height] or fractions [left, top, right, bottom]"; + case "ints": return Array.isArray(v) && (!spec.count || v.length === spec.count) && v.every(isInt) ? null : `${key} is ${spec.count ? spec.count + " " : ""}whole numbers: [${Array(spec.count || 2).fill("0").join(", ")}]`; + case "option": { + const options = spec.options || []; + const one = (x) => typeof x === "string" && options.includes(x); + if (spec.count) return Array.isArray(v) && v.length === spec.count && v.every(one) ? null : `${key} is [${Array(spec.count).fill('"…"').join(", ")}] of: ${options.join(", ")}`; + if (v === null || one(v)) return null; + const near = typeof v === "string" ? closest(v, options) : null; + return `${key} is one of: ${options.join(", ")}` + (near ? ` (did you mean "${near}"?)` : "") + ": PortMaster uses its default instead"; + } + case "text": return typeof v === "string" ? null : `${key} is text: "…"`; + case "file": case "image": { + if (v === null) return null; + if (typeof v !== "string") return `${key} is a file name: "…"`; + if (v.includes("{") || v.startsWith("qr:")) return null; + const files = type === "image" ? [...edit.schema.files.image, ...names.resources] : [...edit.schema.files.font, ...edit.schema.files.sound, ...names.resources]; + if (key === "font" && !files.includes(v)) return `there's no font "${v}" in this theme or PortMaster`; + if (type === "image" && !files.includes(v)) return `there's no image "${v}" in this theme or its #resources`; + return null; + } + case "outline": return Array.isArray(v) && v.length === 2 && !colorProblem(v[0], names.colors) && isInt(v[1]) ? null : `${key} is [color, thickness]`; + case "list": return v === null || Array.isArray(v) ? null : `${key} is a list: [ … ]`; + default: return null; + } +} + +/* [{from, to, severity, message}] for the text. */ +function check(text) { + let parsed; + try { parsed = parseJSON(text); } + catch (error) { + if (!(error instanceof JSONError)) throw error; + return { parsed: null, problems: [{ from: error.pos, to: Math.min(error.pos + 1, text.length), severity: "error", message: error.message }] }; + } + const s = edit.schema, problems = []; + const names = mergedNames(parsed.value); + const sections = Object.keys(s.sections); + const warn = (from, to, message, severity = "warning") => problems.push({ from, to, severity, message }); + for (const dup of parsed.duplicates) warn(dup.keyFrom, dup.keyTo, `"${dup.path[dup.path.length - 1]}" is here twice: only the last one counts`); + for (const node of parsed.nodes) { + const key = node.path[node.path.length - 1]; + if (typeof key !== "string") continue; + const parent = node.path.slice(0, -1), kind = kindOf(parent), name = bare(key); + const at = [node.keyFrom, node.keyTo]; + if (kind === "top") { + if (key.startsWith("#") && !(key in s.top)) warn(...at, `PortMaster doesn't use "${key}"` + hint(key, Object.keys(s.top))); + else if (!key.startsWith("#") && !sections.includes(name)) warn(...at, `There's no screen called "${name}"` + hint(name, sections), "info"); + } else if (kind === "scheme") { + if (key.startsWith("#") && !["#pallet", "#resources", "#base"].includes(key)) warn(...at, `A color scheme can have #pallet, #resources and sections, not "${key}"`); + } else if (kind === "info") { + if (!(key in s.info)) warn(...at, `#info has ${Object.keys(s.info).join(", ")}`, "info"); + else if (key === "default-scheme" && !names.schemes.includes(node.value)) warn(node.from, node.to, `There's no color scheme "${node.value}" in #schemes`); + } else if (kind === "section") { + if (key.startsWith("#element:")) { + const element = key.slice(9).split(":")[0].replace(/\[.*$/, ""); + if (!names.elements.includes(element)) warn(...at, `There's no "${element}" in #elements` + hint(element, names.elements)); + } + if (node.value !== null && typeof node.value !== "object" && key !== "#config") warn(node.from, node.to, "A region is an object: { … }", "error"); + } else if (kind === "region") { + if (!(name in s.keys)) { warn(...at, `PortMaster doesn't know "${name}": it's ignored` + hint(name, Object.keys(s.keys))); continue; } + const problem = valueProblem(name, node.value, names); + /* PortMaster quietly uses the default for an option it doesn't know, a missing file + shows nothing; the rest stop the screen loading. */ + if (problem) warn(node.from, node.to, problem, ["image", "file", "option"].includes(s.keys[name].type) ? "warning" : "error"); + if (name === "parent" && typeof node.value === "string" && node.value !== "root" && !names.elements.includes(node.value)) { + const regions = Object.keys((parsed.value[parent[0]] || {})).filter((r) => !r.startsWith("#")); + if (!regions.includes(node.value)) warn(node.from, node.to, `There's no "${node.value}" in #elements or this section, so it's the whole screen` + hint(node.value, [...names.elements, ...regions])); + } + } else if (kind === "pallet") { + if (colorProblem(node.value, [])) warn(node.from, node.to, "A #pallet color is \"#rrggbb\" or [r, g, b] / [r, g, b, a]", "error"); + } + } + return { parsed, problems }; +} +const hint = (word, words) => { const near = closest(word, words); return near ? `: did you mean "${near}"?` : ""; }; + +/* The theme's names, from the text being typed (so a new color can be used straight away) + and from the server (its files). */ +function mergedNames(value) { + const names = { colors: new Set(), resources: new Set(edit.schema.names.resources), elements: new Set(), schemes: [] }; + const take = (section) => { + if (!section || typeof section !== "object") return; + for (const k of Object.keys(section["#pallet"] || {})) names.colors.add(k); + for (const [file, info] of Object.entries(section["#resources"] || {})) { + names.resources.add(file); + if (info && typeof info === "object") { + if (typeof info.name === "string") names.resources.add(info.name); + for (const part of Object.keys(info.atlas || {})) names.resources.add(part); + } + } + }; + take(value); + for (const [name, scheme] of Object.entries(value["#schemes"] || {})) { take(scheme); if (!name.startsWith("#")) names.schemes.push(name); } + for (const k of Object.keys(value["#elements"] || {})) names.elements.add(k); + return { colors: [...names.colors], resources: [...names.resources], elements: [...names.elements], schemes: names.schemes }; +} + +/* ---------------------------------------------------------------- Where the cursor is */ +/* A forgiving scan of the text up to index: the path of the object or array it's in, and + whether it's in a key, a string value or a bare value. Works on half-typed text. */ +function contextAt(text, index) { + const stack = [{ type: "obj", key: null, expectKey: false, name: null, top: true }]; + let i = 0; + /* stack[0] is outside the file, stack[1] the file's { }: paths start below that. */ + const path = () => stack.slice(2).map((f) => f.name); + /* The object a value belongs to, and its key: for [ ... ] values, the object holding the array. */ + const owner = () => { + let at = stack.length - 1; + while (at > 0 && stack[at].type === "arr") at--; + return { key: stack[at].key, path: stack.slice(2, at + 1).map((f) => f.name), inArray: at !== stack.length - 1 }; + }; + while (i < index) { + const c = text[i]; + const top = stack[stack.length - 1]; + if (c === '"') { + const start = i; i++; + let out = ""; + while (i < text.length && text[i] !== '"' && text[i] !== "\n") { + if (text[i] === "\\") { out += text[i + 1] === "n" ? "\n" : text[i + 1] || ""; i += 2; continue; } + out += text[i]; i++; + } + if (i >= index) { + const isKey = top.type === "obj" && top.expectKey; + return { in: isKey ? "key" : "value", ...owner(), key: isKey ? null : owner().key, path: isKey ? path() : owner().path, + prefix: text.slice(start + 1, index), from: start + 1, closed: text[i] === '"', end: i }; + } + i++; + if (top.type === "obj" && top.expectKey) { top.key = out; top.expectKey = false; } + continue; + } + if (c === "{" || c === "[") { + const name = top.type === "obj" ? top.key : (top.index || 0); + stack.push({ type: c === "{" ? "obj" : "arr", key: null, expectKey: c === "{", name, index: 0 }); + } else if (c === "}" || c === "]") { + if (stack.length > 1) stack.pop(); + } else if (c === ",") { + if (top.type === "obj") { top.expectKey = true; top.key = null; } else top.index = (top.index || 0) + 1; + } else if (c === ":") { + if (top.type === "obj") top.expectKey = false; + } + i++; + } + const top = stack[stack.length - 1]; + if (stack.length === 1 && !top.key) return { in: "start", path: [] }; + const word = text.slice(0, index).match(/[\w.-]*$/)[0]; + if (top.type === "obj" && top.expectKey) return { in: "key-bare", path: path(), prefix: word, from: index - word.length }; + return { in: "bare", ...owner(), prefix: word, from: index - word.length }; +} + +/* ---------------------------------------------------------------- Suggestions */ +function keysFor(kind, path, value) { + const s = edit.schema; + const doc = (key) => { const spec = s.keys[key]; if (!spec) return ""; return spec.options ? spec.options.slice(0, 4).join(" | ") + (spec.options.length > 4 ? " …" : "") : spec.type + (spec.count ? ` ×${spec.count}` : ""); }; + switch (kind) { + case "top": return [...Object.entries(s.top).map(([k, d]) => [k, d]), ...Object.keys(s.sections).map((k) => [k, "screen"])]; + case "info": return Object.keys(s.info).map((k) => [k, s.info[k].type]); + case "region": return Object.keys(s.keys).map((k) => [k, doc(k)]); + case "section": { + const section = bare(path[path.length - 1]); + const names = value ? mergedNames(value) : { elements: s.names.elements }; + return [["#config", "buttons"], ["#base", "keys every region here starts from"], + ...(s.sections[section] || []).map((r) => [r, "region"]), + ...names.elements.map((e) => ["#element:" + e, "a region from #elements"])]; + } + case "scheme": return [["#pallet", "named colors"], ["#resources", "image changes"], ...Object.keys(s.sections).map((k) => [k, "screen"])]; + case "config": return Object.keys(s.config).map((k) => [k, s.config[k].type]); + case "override": return s.special_words.map((w) => [w, "special word"]); + case "pallet": return (value ? mergedNames(value).colors : s.names.colors).map((c) => [c, "color"]); + default: return []; + } +} +function valuesFor(key, value, path) { + const s = edit.schema, name = bare(key || ""); + const kind = kindOf(path); + const names = value ? mergedNames(value) : s.names; + if (kind === "info" && name === "default-scheme") return names.schemes.map((x) => [x, "color scheme"]); + if (kind !== "region") return []; + const spec = s.keys[name]; + if (!spec) return []; + if (spec.options) return spec.options.map((o) => [o, name]); + if (spec.type === "color") return names.colors.map((c) => [c, "color"]); + if (name === "font") return s.files.font.map((f) => [f, "font"]); + if (spec.type === "image") return [...s.files.image.map((f) => [f, "image file"]), ...names.resources.filter((r) => !s.files.image.includes(r)).map((r) => [r, "resource"]), ["qr:", "a QR code of the text after it"]]; + if (/sound/.test(name) || name === "music") return [...s.files.sound, ...names.resources.filter((r) => /\.(ogg|wav|mp3)$/i.test(r) || !r.includes("."))].map((f) => [f, "sound"]); + if (name === "parent") return [["root", "the whole screen"], ...names.elements.map((e) => [e, "element"])]; + return []; +} + +function hintAt(cm, options) { + if (!edit.schema) return null; + const text = cm.getValue(), cursor = cm.getCursor(), index = cm.indexFromPos(cursor); + const ctx = contextAt(text, index); + let value = null; + try { value = JSON.parse(text); } catch (e) { /* half-typed: the server's names */ } + /* items [[text, doc]] matching what's typed since filterFrom, replacing from..to. */ + const make = (items, from, to, wrap, filterFrom = from) => { + const prefix = text.slice(filterFrom, index).toLowerCase(); + const list = items.filter(([t]) => !prefix || t.toLowerCase().includes(prefix)) + .sort(([a], [b]) => (b.toLowerCase().startsWith(prefix) - a.toLowerCase().startsWith(prefix))) + .slice(0, 200) + .map(([t, d]) => ({ text: wrap(t), displayText: t, render: (el) => { el.textContent = t; if (d) { const small = document.createElement("span"); small.className = "hint-doc"; small.textContent = d; el.append(small); } } })); + if (!list.length) return null; + return { list, from: cm.posFromIndex(from), to: cm.posFromIndex(to) }; + }; + if (ctx.in === "key" || ctx.in === "key-bare") { + const kind = kindOf(ctx.path); + /* "key[" : a capability. */ + const bracket = ctx.in === "key" ? ctx.prefix.lastIndexOf("[") : -1; + if (bracket >= 0 && !ctx.prefix.slice(bracket).includes("]")) { + const sep = Math.max(ctx.prefix.lastIndexOf("|"), ctx.prefix.lastIndexOf(","), bracket); + const from = ctx.from + sep + 1 + (ctx.prefix[sep + 1] === "!" ? 1 : 0); + return make(edit.schema.capabilities.map((c) => [c, "capability"]), from, index, (t) => t); + } + const items = keysFor(kind, ctx.path, value); + if (ctx.in === "key") { + const end = ctx.closed ? ctx.end + 1 : index; + const colon = ctx.closed && /^\s*:/.test(text.slice(end)); + return make(items, ctx.from - 1, end, (t) => `"${t}"` + (colon ? "" : ": "), ctx.from); + } + return make(items, ctx.from, index, (t) => `"${t}": `); + } + if (ctx.in === "value") { + /* In a string: {tags}, or the key's values. */ + const brace = ctx.prefix.lastIndexOf("{"); + if (brace >= 0 && !ctx.prefix.slice(brace).includes("}")) { + const tags = [...edit.schema.tags.map((t) => [t, "tag"]), ["if:", "{if:tag}…{else}…{endif}"], ["else", ""], ["endif", ""]]; + const from = ctx.from + brace + 1 + (ctx.prefix.slice(brace + 1).startsWith("if:") ? 3 + (ctx.prefix[brace + 4] === "!" ? 1 : 0) : 0); + return make(tags, from, index, (t) => (t.endsWith(":") ? t : t + "}")); + } + const items = valuesFor(ctx.key, value, ctx.path); + const end = ctx.closed ? ctx.end + 1 : index; + return make(items, ctx.from - 1, end, (t) => `"${t}"`, ctx.from); + } + if (ctx.in === "bare") { + const spec = edit.schema.keys[bare(ctx.key || "")]; + if (spec && spec.type === "bool" && kindOf(ctx.path) === "region") return make([["true", ""], ["false", ""]], ctx.from, index, (t) => t); + const items = valuesFor(ctx.key, value, ctx.path); + if (items.length) return make(items, ctx.from, index, (t) => `"${t}"`); + } + return null; +} + +/* ---------------------------------------------------------------- The page */ +function loadAssets() { + if (window.CodeMirror) return Promise.resolve(); + if (edit.assets) return edit.assets; + edit.assets = new Promise((resolve, reject) => { + const css = document.createElement("link"); css.rel = "stylesheet"; css.href = "/static/codemirror.css"; document.head.append(css); + const js = document.createElement("script"); js.src = "/static/codemirror.js"; js.onload = resolve; js.onerror = () => reject(new Error("Can't load the editor")); + document.head.append(js); + }); + return edit.assets; +} + +async function buildEdit() { + const pane = $("edit"); + $("estate").textContent = "Loading…"; + try { + await loadAssets(); + const [file, schemaInfo] = await Promise.all([ + fetch("/api/theme_file?" + new URLSearchParams({ theme: state.theme })).then(jsonOrThrow), + fetch("/api/schema?" + new URLSearchParams({ theme: state.theme })).then(jsonOrThrow)]); + edit.schema = schemaInfo; + if (!edit.cm) makeEditor(); + if (edit.loaded !== state.theme || !edit.dirty) loadText(file); + else edit.file = { ...edit.file, writable: file.writable, can_copy: file.can_copy }; + } catch (error) { + $("estate").textContent = error.message; + return; + } + fillShots(); + updateState(); + edit.cm.refresh(); + preview(); + pane.hidden = false; +} + +async function jsonOrThrow(r) { + const body = await r.json().catch(() => ({})); + if (!r.ok) { const error = new Error(body.error || r.statusText); error.status = r.status; error.body = body; throw error; } + return body; +} + +function lintText(text, options, cm) { + if (!edit.schema) return []; + const { problems } = check(text); + edit.problems = problems; + showProblems(); + return problems.map((p) => ({ from: cm.posFromIndex(p.from), to: cm.posFromIndex(Math.max(p.to, p.from + 1)), severity: p.severity === "info" ? "warning" : p.severity, message: p.message })); +} + +function makeEditor() { + edit.cm = CodeMirror.fromTextArea($("etext"), { + mode: { name: "javascript", json: true }, lineNumbers: true, indentUnit: 4, tabSize: 4, indentWithTabs: false, + matchBrackets: true, autoCloseBrackets: true, styleActiveLine: true, foldGutter: true, + gutters: ["CodeMirror-lint-markers", "CodeMirror-linenumbers", "CodeMirror-foldgutter"], + lint: { getAnnotations: lintText, delay: 250 }, + hintOptions: { hint: hintAt, completeSingle: false, closeCharacters: /[\s()[\]{};>,]/ }, + extraKeys: { + "Ctrl-Space": "autocomplete", "Cmd-Space": "autocomplete", + "Ctrl-S": () => saveTheme(), "Cmd-S": () => saveTheme(), + Tab: (cm) => (cm.somethingSelected() ? cm.indentSelection("add") : cm.replaceSelection(" ", "end")), + "Shift-Tab": (cm) => cm.indentSelection("subtract"), + }, + }); + edit.cm.on("change", (cm, change) => { + if (change.origin === "setValue") return; + edit.dirty = !cm.isClean(edit.clean); + updateState(); + clearTimeout(edit.draftTimer); + edit.draftTimer = setTimeout(draft, 700); + }); + edit.cm.on("inputRead", (cm, change) => { + const typed = change.text.join(""); + /* Suggest as you type: in a key, after { in a string, after [ in a key, after ": ". */ + if (/^[\w"{[#|:!-]$/.test(typed) && !cm.state.completionActive) cm.showHint({ completeSingle: false }); + }); + edit.cm.on("cursorActivity", () => highlightRegionAtCursor()); +} + +function loadText(file) { + edit.file = file; + edit.loaded = file.theme; + const cursor = edit.cm.getCursor(), scroll = edit.cm.getScrollInfo(); + edit.cm.setValue(file.text); + edit.cm.clearHistory(); + edit.clean = edit.cm.changeGeneration(); + edit.dirty = false; + edit.previewTheme = null; + if (cursor.line < edit.cm.lineCount()) { edit.cm.setCursor(cursor); edit.cm.scrollTo(scroll.left, scroll.top); } + updateState(); +} + +function updateState() { + if (!edit.file) return; + const file = edit.file; + $("esave").disabled = !file.writable || !edit.dirty || edit.busy; + $("erevert").disabled = !edit.dirty || edit.busy; + $("ecopy").hidden = !file.can_copy; + let text; + if (!file.writable) text = file.can_copy ? "Built-in or read-only theme: changes show in the preview, “Copy to a new theme” keeps them" : "This theme can't be changed here: changes only show in the preview"; + else text = edit.dirty ? "Unsaved changes · Ctrl+S saves" : "Saved"; + if (file.writable && file.downloaded) text += " · downloaded theme: updating it from the theme list replaces your changes, a copy keeps them"; + $("estate").textContent = text; + $("estate").classList.toggle("dirty", edit.dirty); +} + +function fillShots() { + const select = $("eshot"); + if (!select.options.length) for (const [name, title] of state.info.shots) option(select, name, title); + if (!state.info.shots.some(([name]) => name === edit.shot)) edit.shot = state.info.shots[0][0]; + select.value = edit.shot; +} + +/* Unsaved text: a draft theme on the server, rendered instead of the saved one. */ +async function draft() { + const text = edit.cm.getValue(); + if (!edit.dirty) { edit.previewTheme = null; preview(); return; } + if (check(text).parsed === null) return; // the preview keeps the last good text + try { + const answer = await fetch("/api/theme_draft", { method: "POST", headers: postHeaders(), body: JSON.stringify({ theme: edit.loaded, text }) }).then(jsonOrThrow); + edit.previewTheme = answer.theme; + preview(); + } catch (error) { $("estate").textContent = `Preview failed: ${error.message}`; } +} +const postHeaders = () => ({ "Content-Type": "application/json", "X-Theme-Server": "1" }); + +async function preview() { + if (state.view !== "edit") return; + if (edit.previewController) edit.previewController.abort(); + const controller = edit.previewController = new AbortController(); + const view = $("eshotview"); + view.classList.add("rendering"); + const query = new URLSearchParams({ theme: edit.previewTheme || state.theme, scheme: state.scheme, res: state.res, lang: state.lang, shot: edit.shot }); + try { + const info = await fetch("/api/render.json?" + query, { signal: controller.signal }).then(jsonOrThrow); + edit.previewInfo = info; + const shot = document.createElement("div"); shot.className = "shot"; + const [w, h] = info.resolution.split("x").map(Number); + shot.style.setProperty("--aspect", `${w}/${h}`); + if (info.missing) { shot.innerHTML = `
`; shot.firstChild.textContent = info.missing; } + else { + const img = new Image(); img.alt = edit.shot; + img.src = "/api/render.png?" + query + "&v=" + info.version; + await img.decode().catch(() => {}); + if (controller.signal.aborted) return; + shot.append(img); + const layer = boxes(info, false); + layer.classList.add("edit-boxes"); + layer.hidden = !$("eboxes").checked; + for (const box of layer.children) { + box.addEventListener("click", (event) => { event.stopPropagation(); jumpToRegion(info.regions[box.dataset.index]); }); + box.title = info.regions[box.dataset.index].name; + } + shot.append(layer); + } + view.replaceChildren(shot); + showProblems(); + highlightRegionAtCursor(); + } catch (error) { + if (controller.signal.aborted) return; + view.innerHTML = `
`; view.firstChild.textContent = error.message; + } finally { + if (edit.previewController === controller) view.classList.remove("rendering"); + } +} + +/* The problems the checks found, and the render's errors, as a list that jumps to them. */ +function showProblems() { + if (!edit.cm) return; // (the first check runs while CodeMirror is being made) + const list = $("eproblems"); + list.replaceChildren(); + const problems = edit.problems || []; + const renderErrors = (edit.previewInfo && edit.previewInfo.errors) || []; + for (const error of renderErrors) { + const li = document.createElement("li"); li.className = "error"; li.textContent = `Render: ${error}`; + list.append(li); + } + const order = { error: 0, warning: 1, info: 2 }; + for (const p of [...problems].sort((a, b) => order[a.severity] - order[b.severity] || a.from - b.from).slice(0, 100)) { + const li = document.createElement("li"); li.className = p.severity; + const pos = edit.cm.posFromIndex(p.from); + li.textContent = `${pos.line + 1}: ${p.message}`; + li.onclick = () => { edit.cm.focus(); edit.cm.setSelection(pos, edit.cm.posFromIndex(Math.max(p.to, p.from + 1))); edit.cm.scrollIntoView(pos, 80); }; + list.append(li); + } + $("eproblems-title").textContent = problems.length + renderErrors.length ? `Problems (${problems.length + renderErrors.length})` : "No problems"; +} + +/* A region box clicked: its place in theme.json (the section's region, else its #elements entry). */ +function jumpToRegion(region) { + let parsed; + try { parsed = parseJSON(edit.cm.getValue()); } catch (error) { return; } + const byKey = (path) => parsed.nodes.find((n) => n.path.length === path.length && n.path.every((k, i) => bare(k) === bare(path[i]))); + const node = byKey([region.section, region.name]) || byKey([region.section, "#element:" + region.name]) || + parsed.nodes.find((n) => n.path.length === 2 && n.path[0] === region.section && typeof n.path[1] === "string" && n.path[1].startsWith("#element:") && n.path[1].endsWith(":" + region.name)) || + byKey(["#elements", region.name]) || byKey([region.section]); + if (!node) return; + const from = edit.cm.posFromIndex(node.keyFrom), to = edit.cm.posFromIndex(node.keyTo); + edit.cm.focus(); + edit.cm.setSelection(from, to); + edit.cm.scrollIntoView({ from, to }, 120); +} + +/* The cursor in a region of this screen's section: its box lights up. */ +function highlightRegionAtCursor() { + const info = edit.previewInfo; + if (!info || !edit.cm) return; + const ctx = contextAt(edit.cm.getValue(), edit.cm.indexFromPos(edit.cm.getCursor())); + let [section, region] = ctx.path || []; + if (ctx.in === "key" && ctx.path.length === 1) region = ctx.prefix; + for (const box of document.querySelectorAll("#eshotview .box")) { + const r = info.regions[box.dataset.index]; + const name = region ? bare(region).replace(/^#element:(?:[^:]*:)?/, "") : null; + box.classList.toggle("hot", !!name && (bare(section) === r.section || section === "#elements") && r.name === name); + } +} + +async function saveTheme(overwrite) { + if (!edit.file || !edit.file.writable || edit.busy) return; + const text = edit.cm.getValue(); + const result = check(text); + if (result.parsed === null) { + const p = result.problems[0], pos = edit.cm.posFromIndex(p.from); + $("estate").textContent = `Not saved: line ${pos.line + 1}: ${p.message}`; + edit.cm.setCursor(pos); + return; + } + edit.busy = true; updateState(); + try { + const answer = await fetch("/api/theme_file", { method: "POST", headers: postHeaders(), + body: JSON.stringify({ theme: edit.loaded, text, version: overwrite || edit.file.version }) }).then(jsonOrThrow); + edit.file.version = answer.version; edit.file.text = text; + edit.clean = edit.cm.changeGeneration(); + edit.dirty = false; edit.previewTheme = null; + edit.busy = false; updateState(); + $("estate").textContent = `Saved ${new Date().toLocaleTimeString()} · the old one is theme.json.bak`; + poll(); + preview(); + } catch (error) { + edit.busy = false; updateState(); + if (error.status === 409) { + if (confirm("theme.json was changed on disk since you opened it.\n\nOK: save yours over it (the one on disk becomes theme.json.bak).\nCancel: keep editing, nothing is saved.")) saveTheme(error.body.version); + return; + } + $("estate").textContent = `Not saved: ${error.message}`; + } +} + +async function revert() { + if (edit.dirty && !confirm("Throw away your changes since the last save?")) return; + const file = await fetch("/api/theme_file?" + new URLSearchParams({ theme: edit.loaded })).then(jsonOrThrow); + loadText(file); + preview(); +} + +async function copyTheme() { + const current = theme(); + const name = prompt(`A name for the new theme (a copy of ${current.name}${edit.dirty ? ", with your changes" : ""}):`, `${current.name} copy`); + if (!name) return; + try { + /* Unsaved changes go into the copy (if they're valid JSON), not the original. */ + const body = { theme: edit.loaded, name }; + if (edit.dirty && check(edit.cm.getValue()).parsed !== null) body.text = edit.cm.getValue(); + const answer = await fetch("/api/theme_copy", { method: "POST", headers: postHeaders(), body: JSON.stringify(body) }).then(jsonOrThrow); + state.info = await fetch("/api/info").then(jsonOrThrow); + state.theme = answer.theme; + fillControls(); + edit.dirty = false; + save(); + state.version = null; + await poll(); + await buildEdit(); + $("estate").textContent = `Copied to ${answer.theme}: changes here are saved to it`; + } catch (error) { + alert(`Not copied: ${error.message}`); + } +} + +/* The theme changed on disk (poll): if nothing is unsaved, load it; else say so. */ +async function editThemeChanged() { + if (!edit.file || state.view !== "edit") return; + let file; + try { file = await fetch("/api/theme_file?" + new URLSearchParams({ theme: edit.loaded })).then(jsonOrThrow); } catch (e) { return; } + if (file.version === edit.file.version) { preview(); return; } + if (!edit.dirty) { loadText(file); preview(); $("estate").textContent = "Loaded the changes made on disk"; } + else $("estate").textContent = "theme.json changed on disk: Revert loads it, Save keeps yours"; +} + +$("esave").onclick = () => saveTheme(); +$("erevert").onclick = () => revert(); +$("ecopy").onclick = () => copyTheme(); +$("eshot").onchange = () => { edit.shot = $("eshot").value; preview(); }; +$("eboxes").onchange = () => { const layer = document.querySelector("#eshotview .boxes"); if (layer) layer.hidden = !$("eboxes").checked; }; +$("eformat").onclick = () => { + try { const value = JSON.parse(edit.cm.getValue()); if (confirm("Rewrite theme.json with even indents? Arrays go one item a line.")) edit.cm.setValue(JSON.stringify(value, null, 4) + "\n"); } + catch (e) { $("estate").textContent = "Fix the JSON mistakes first"; } +}; +window.addEventListener("beforeunload", (event) => { if (edit.dirty) { event.preventDefault(); event.returnValue = ""; } }); diff --git a/PortMaster/pylibs/theme_server/static/index.html b/PortMaster/pylibs/theme_server/static/index.html index 830a1715..ab2a38ae 100644 --- a/PortMaster/pylibs/theme_server/static/index.html +++ b/PortMaster/pylibs/theme_server/static/index.html @@ -110,12 +110,50 @@ pre.data { font: 11px/1.4 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg); padding: 8px; border-radius: 6px; margin: 0; } .errors { color: var(--error); margin: 0; padding-left: 16px; } @media (max-width: 900px) { .detail { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr) 40%; } .detail aside { border-left: 0; border-top: 1px solid var(--line); } } +/* Edit view */ +.edit { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(280px, 1fr); gap: 16px; align-items: start; } +.ebar { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } +.ebar button, .epreview button { font: 13px var(--sans); color: var(--fg); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 5px 12px; cursor: pointer; } +.ebar button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.ebar button:disabled { opacity: .45; cursor: default; } +#estate { font-size: 12px; color: var(--muted); } +#estate.dirty { color: var(--warn); } +.ehelp { margin-left: auto; font-size: 11px; color: var(--muted); } +.eeditor { min-width: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--surface); } +.eeditor .CodeMirror { height: calc(100vh - 170px); min-height: 360px; font: 13px/1.5 var(--mono); background: var(--surface); color: var(--fg); } +.eeditor .CodeMirror-gutters { background: var(--bg); border-right: 1px solid var(--line); } +.eeditor .CodeMirror-linenumber { color: var(--muted); } +.eeditor .CodeMirror-cursor { border-left-color: var(--fg); } +.eeditor .CodeMirror-activeline-background { background: rgba(var(--box), .07); } +.eeditor .CodeMirror-selected, .eeditor .CodeMirror-focused .CodeMirror-selected { background: rgba(var(--box), .22); } +.eeditor .cm-string.cm-property { color: var(--accent); } +.eeditor .cm-string { color: #1f7a4d; } .eeditor .cm-number { color: #a0522d; } .eeditor .cm-atom { color: #8a3ab9; } +:root[data-theme="dark"] .eeditor .cm-string { color: #8fd19e; } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .eeditor .cm-string { color: #8fd19e; } :root:not([data-theme="light"]) .eeditor .cm-number { color: #f0a46b; } :root:not([data-theme="light"]) .eeditor .cm-atom { color: #d7a6f5; } } +.eeditor .CodeMirror-matchingbracket { color: inherit !important; background: rgba(var(--box), .25); } +.CodeMirror-hints { font: 12px var(--mono); background: var(--surface); color: var(--fg); border-color: var(--line); max-height: 18em; z-index: 50; } +.CodeMirror-hint { color: var(--fg); display: flex; justify-content: space-between; gap: 16px; } +li.CodeMirror-hint-active { background: var(--accent); color: #fff; } +.hint-doc { color: var(--muted); font-size: 11px; } li.CodeMirror-hint-active .hint-doc { color: #e6ecff; } +.CodeMirror-lint-tooltip { font: 12px var(--sans); background: var(--surface); color: var(--fg); border: 1px solid var(--line); z-index: 60; } +.epreview { position: sticky; top: 92px; display: grid; gap: 10px; max-height: calc(100vh - 110px); overflow: auto; } +.epreview .row { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } +#eshotview { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--surface); transition: opacity .2s; } +#eshotview.rendering { opacity: .6; } +#eshotview .box { pointer-events: auto; cursor: pointer; } +#eshotview .boxes { pointer-events: none; } +.epreview h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 4px 0 0; } +#eproblems { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; font-size: 12px; } +#eproblems li { padding: 3px 6px; border-radius: 4px; cursor: pointer; border-left: 3px solid var(--line); } +#eproblems li:hover { background: rgba(var(--box), .1); } +#eproblems li.error { border-left-color: var(--error); } #eproblems li.warning { border-left-color: var(--warn); } #eproblems li.info { border-left-color: var(--muted); color: var(--muted); } +@media (max-width: 900px) { .edit { grid-template-columns: 1fr; } .epreview { position: static; max-height: none; } .eeditor .CodeMirror { height: 60vh; } .ehelp { display: none; } }

Theme Preview

- + @@ -149,6 +187,26 @@

Theme Preview

+
@@ -224,10 +282,11 @@

Region

for (const el of document.querySelectorAll(".preview-only")) el.hidden = view !== "preview" || (el.id === "mode-shot-field" && state.mode !== "resolutions"); $("grid").hidden = view !== "preview"; $("translate").hidden = view !== "translate"; + $("edit").hidden = view !== "edit"; fillLanguages(); save(); state.version = null; - poll().then(() => (view === "translate" ? buildTranslate() : build())); + poll().then(() => (view === "translate" ? buildTranslate() : view === "edit" ? buildEdit() : build())); } function fillSchemes() { const t = theme(); @@ -484,8 +543,13 @@

Region

}); /* Controls */ -function changed() { cancelRenders(); save(); if (state.view === "translate") buildTranslate(); else build(); } -$("theme").onchange = () => { state.theme = $("theme").value; state.version = null; fillSchemes(); poll().then(changed); }; +function changed() { cancelRenders(); save(); if (state.view === "translate") buildTranslate(); else if (state.view === "edit") preview(); else build(); } +$("theme").onchange = () => { + if (state.view === "edit" && edit.dirty && !confirm("You have unsaved changes to this theme. Switch theme and lose them?")) { $("theme").value = state.theme; return; } + state.theme = $("theme").value; state.version = null; fillSchemes(); + if (state.view === "edit") { edit.dirty = false; poll().then(() => { save(); buildEdit(); }); return; } + poll().then(changed); +}; $("scheme").onchange = () => { state.scheme = $("scheme").value; changed(); }; $("res").onchange = () => { $("custom-res-field").hidden = $("res").value !== "custom"; @@ -698,6 +762,7 @@

Region

cancelRenders(); const detail = state.detail; if (state.view === "translate") { if (!state.usageKey) scanUsage(); showPreview(); } + if (state.view === "edit") editThemeChanged(); for (const card of state.view === "preview" ? state.cards || [] : []) fill(card); if (detail !== null) Promise.all(state.cards.map((c) => render(c.shot, c.res))).then(() => { if (state.detail === detail) openDetail(detail); }); // Refresh the scheme list too, in case theme.json changed it. @@ -712,7 +777,8 @@

Region

$("status").innerHTML = t ? `● live ${t.name}${lastChange ? ` · updated ${lastChange.toLocaleTimeString()}` : ""}` : ""; } -(async function main() { +/* Started once editor.js (after this script) has loaded too. */ +window.addEventListener("DOMContentLoaded", async function main() { load(); try { state.info = await (await fetch("/api/info")).json(); @@ -720,9 +786,16 @@

Region

$("status").textContent = "Can't reach the server."; return; } fillControls(); - setView(state.view === "translate" ? "translate" : "preview"); + setView(["translate", "edit"].includes(state.view) ? state.view : "preview"); setInterval(poll, 1000); -})(); +}); + + + diff --git a/THEME.md b/THEME.md index 91d45312..9c9d4889 100644 --- a/THEME.md +++ b/THEME.md @@ -35,12 +35,31 @@ To write the screens to files instead, for a theme's README or to compare before That writes `out/////.png`, with a `.json` next to each listing its regions. +### Editing theme.json in the page + +Pick **Edit** at the top to change the theme's `theme.json` in the page, with one screen beside it. +- **Preview as you type:** the screen redraws a moment after you stop typing, from your unsaved text. Your theme isn't changed until you save. Pick the screen, color scheme, size and language as usual. +- **Region boxes:** click a box to jump to that region in `theme.json`. Put the cursor in a region of the screen's section to light up its box. +- **Suggestions:** they appear as you type, or press Ctrl+Space. They cover keys (with their type), the values a key takes (alignments, your `#pallet` colors, the theme's images and fonts, `#elements` for `parent`), `{tags}` in text, and `[capabilities]` in keys. They come from PortMaster's own code, so they match what it accepts. +- **Checks:** + - JSON mistakes, in plain words: a missing comma, a comma before `}`, single quotes, comments + - keys PortMaster doesn't know, which it ignores, with "did you mean" for typos like `barwidth` (it's `bar-width`) + - values of the wrong type + - colors that aren't in `#pallet`, and images and fonts that aren't there + - `#element:` and `parent` names that don't exist + - errors pugwash logged while drawing the screen + + The list under the screen jumps to each one. +- **Saving:** Save (Ctrl/⌘+S) writes `theme.json`. The previous version is kept as `theme.json.bak`. If the file changed on disk since you opened it, you're asked before it's overwritten. If you edit `theme.json` in another editor while the page is open, the page loads the new version, unless you have unsaved changes there. +- **A new theme:** the built-in default theme can't be changed. **Copy to a new theme…** copies any theme, your unsaved changes included, into the themes folder under a new name, and you carry on editing the copy. +- **Downloaded themes:** you can edit a theme downloaded from PortMaster's theme list, but downloading an update of it there replaces your changes. Copy it first to keep them. + ### On your device -PortMaster carries the same server: **Options → Theme Editor** starts it on the device, and shows its address as text and as a QR code. Each device picks a random port the first time and keeps it, so the address stays the same. Open it on a computer or phone on the same network: no checkout or install needed. It previews the themes installed on the device, with the device's own ports list, and the translation editor saves into the device's PortMaster. Press B on the device to stop it. +PortMaster carries the same server: **Options → Theme Editor** starts it on the device, and shows its address as text and as a QR code. Each device picks a random port the first time and keeps it, so the address stays the same. Open it on a computer or phone on the same network: no checkout or install needed. It previews the themes installed on the device, with the device's own ports list. **Edit** saves into the device's `PortMaster/themes/`, and a copy made there shows up in PortMaster's theme list. The translation editor saves into the device's PortMaster. Press B on the device to stop it. - Translations saved on a device are in `PortMaster/pylibs/locales/` there. Copy them off before updating PortMaster, which replaces that folder. -- On a handheld (an RG35XX-H) the first screen takes 2-3 seconds, a new screen size about 2, switching theme, color scheme or language (or saving) about a second, and each other screen half a second. Each screen size has a render process; how many are kept at once depends on the device's free memory. +- On a handheld (an RG35XX-H) the first screen takes 2-3 seconds, a new screen size about 2, switching theme, color scheme or language (or saving) about a second, and each other screen half a second. In the Edit view, the preview takes about 2.5 seconds to catch up with your typing. Each screen size has a render process; how many are kept at once depends on the device's free memory. - Anyone on the same network can open it while it runs. ### Translating PortMaster diff --git a/tests/test_theme_editor.py b/tests/test_theme_editor.py new file mode 100644 index 00000000..8529bb14 --- /dev/null +++ b/tests/test_theme_editor.py @@ -0,0 +1,426 @@ +# SPDX-License-Identifier: MIT +# +# The theme server's Edit view, server side: what it knows about theme.json (theme_server/ +# schema.py, read from pugwash's code), and its files (theme_server/editor.py): reading and +# saving theme.json, drafts for the preview, copying a theme. The HTTP side is in +# test_theme_server_http.py. + +import json +import os +import re + +import pytest + +from conftest import PM_DIR + +from theme_server import editor as theme_editor, schema, serve + + +################################################################################ +## schema: region keys from region.py +@pytest.fixture(scope="module") +def keys(): + return schema.region_keys() + + +def test_region_key_types(keys): + assert keys['font-size'] == {'type': "int"} + assert keys['font-scale']['type'] == "float" + assert keys['area'] == {'type': "rect"} + assert keys['font-color'] == {'type': "color"} + assert keys['text-wrap'] == {'type': "bool"} + assert keys['image-size'] == {'type': "ints", 'count': 2} + assert keys['patch'] == {'type': "ints", 'count': 4} + assert keys['click-sound-volume'] == {'type': "int", 'min': 0, 'max': 128} + assert keys['list-columns'] == {'type': "int", 'min': 1, 'max': 6} + assert keys['image'] == {'type': "image"} and keys['pointer'] == {'type': "image"} + + +def test_region_key_options(keys): + ## Rect.POINTS and BLEND_MODES, found by name. + assert keys['align']['options'][:3] == ["topleft", "midtop", "topcenter"] and "bottomright" in keys['align']['options'] + assert keys['pointer-align']['count'] == 2 and keys['pointer-align']['options'] == keys['align']['options'] + assert set(keys['blend-mode']['options']) == {"none", "blend", "add", "mod", "mul"} + assert keys['image-mode']['options'] == ["fit", "fit-horizontal", "fit-vertical", "stretch", "repeat"] + assert keys['autoscroll']['options'] == ["slide", "marquee"] + + +def test_every_verified_key_is_known(keys): + text = (PM_DIR / "pylibs" / "pugwash" / "sdl" / "region.py").read_text(encoding="utf-8") + verified = set(re.findall(r"_verify_\w+\(\s*'([a-z-]+)'", text)) + assert len(verified) > 60 + assert verified <= set(keys) + + +def test_sections(): + sections = schema.sections() + assert "option_list" in sections["main_menu"] + assert "links_list" in sections["port_links"] + ## A scene's section that the default theme doesn't have still counts. + assert "disclaimer_text" in sections["disclaimer"] + + +def test_template_tags(): + tags = schema.template_tags() + assert {"system.time_24hr", "port_info.title", "port_links.url", "theme_server.url", "ports_list.total_ports"} <= set(tags) + assert all(re.match(r"^[a-z_]+\.[a-z0-9_]+$", tag) for tag in tags) + + +def test_capabilities(tmp_path): + theme = tmp_path / "theme.json" + theme.write_text('{"main_menu": {"r": {"area[!opengl]": [0, 0, 1, 1], "font-size[rg35xx|ko_KR, hires]": 5}}}') + found = schema.capabilities([theme]) + assert {"4:3", "16:9", "ja_JP", "opengl", "rg35xx", "ko_KR", "hires"} <= set(found) + assert not any(cap.startswith("!") for cap in found) + + +def test_theme_files(tmp_path): + (tmp_path / "images").mkdir() + for name in ("a.png", "images/b.JPG", "font.ttf", "click.ogg", "notes.txt"): + (tmp_path / name).write_bytes(b"x") + + files = schema.theme_files(tmp_path) + + assert files['image'] == ["a.png", "images/b.JPG"] + assert files['sound'] == ["click.ogg"] + ## The theme's fonts, then PortMaster's (the Noto fonts even before they're unpacked). + assert files['font'][0] == "font.ttf" and "DejaVuSans.ttf" in files['font'] and "NotoSansJP-Regular.ttf" in files['font'] + + +def test_theme_names(): + data = { + "#pallet": {"top": [1, 2, 3]}, + "#resources": {"buttons.png": {"atlas": {"_A": [0, 0, 1, 1]}}, "click.ogg": {"name": "click"}}, + "#schemes": {"#base": {"#pallet": {"base_color": "#fff"}}, "Dark": {"#pallet": {"dark": "#000"}}, "Light": {}}, + "#elements": {"left_pane": {}, "right_pane": {}}, + } + names = schema.theme_names(data) + assert names['colors'] == ["base_color", "dark", "top"] + assert names['resources'] == ["_A", "buttons.png", "click", "click.ogg"] + assert names['elements'] == ["left_pane", "right_pane"] + assert names['schemes'] == ["Dark", "Light"] + + +@pytest.mark.parametrize("data", [{}, {"#schemes": [1]}, {"#pallet": "x", "#resources": {"a": 5}, "#elements": None}]) +def test_theme_names_odd_themes(data): + schema.theme_names(data) + + +def test_schema_is_json(tmp_path): + whole = schema.schema(PM_DIR / "pylibs" / "default_theme") + assert json.loads(json.dumps(whole)) == whole + assert "Light Mode" in whole['names']['schemes'] and "general_font" in whole['names']['colors'] + assert "#info" in whole['top'] and "default-scheme" in whole['info'] + ## No theme: still everything but the theme's own names. + assert schema.schema()['names']['colors'] == [] + + +def region_key_problems(theme_data): + """[(section, region, key)] of keys PortMaster doesn't know, as the editor's checks find them.""" + known = schema.region_keys() + found = [] + + def check(section, region, data): + for key in data: + if key.split("[", 1)[0] not in known: + found.append((section, region, key)) + + check("#base", "", theme_data.get("#base", {})) + for name, region in theme_data.get("#elements", {}).items(): + check("#elements", name, region) + for section, regions in theme_data.items(): + if section.startswith("#") or not isinstance(regions, dict): + continue + for name, region in regions.items(): + if name != "#config" and isinstance(region, dict): + check(section, name, region) + return found + + +def test_default_theme_keys_are_known(): + data = json.loads((PM_DIR / "pylibs" / "default_theme" / "theme.json").read_text(encoding="utf-8")) + problems = region_key_problems(data) + ## Known: typos PortMaster ignores. Fixing them changes how the default theme looks. + known = [("on_screen_keyboard", "keyboard", "barwidth"), ("filter_list", "filter_list", "list-spacer")] + assert [problem for problem in problems if problem not in known] == [] + + +@pytest.mark.xfail(strict=True, reason="the default theme has barwidth (bar-width) and list-spacer, which PortMaster ignores") +def test_default_theme_has_no_unknown_keys(): + data = json.loads((PM_DIR / "pylibs" / "default_theme" / "theme.json").read_text(encoding="utf-8")) + assert region_key_problems(data) == [] + + +################################################################################ +## editor: checking and renaming theme.json text +@pytest.mark.parametrize("text, error", [ + ('{"a": 1}', None), + ('{}', None), + ('[]', "has to be a JSON object"), + ('5', "has to be a JSON object"), + ('{"a": 1 "b": 2}', "Expecting ',' delimiter"), + ('{"a": 1', "Expecting"), + ('', "Expecting value"), + ]) +def test_check_text(text, error): + problem = theme_editor.check_text(text) + if error is None: + assert problem is None + else: + assert error in problem['error'] and problem['line'] >= 1 + + +def test_check_text_position(): + problem = theme_editor.check_text('{\n "a": 1\n "b": 2\n}') + assert problem['line'] == 3 and problem['column'] == 3 + + +def test_check_text_trailing_comma(): + ## Python 3.13 says so and points at the comma; before, at the } after it. + problem = theme_editor.check_text('{\n "a": 1,\n}') + assert problem['error'] and (problem['line'], problem['column']) in ((2, 9), (3, 1)) + + +def test_rename_keeps_the_layout(): + text = '{\n "#info": {\n "version": 1,\n "name": "Old \\"one\\"",\n "creator": "me"\n },\n "main_menu": { "r": { "area": [ 0, 0, 1, 1 ] } }\n}\n' + renamed = theme_editor.rename_text(text, 'New "two"') + + assert json.loads(renamed)["#info"]["name"] == 'New "two"' + assert renamed.replace('New \\"two\\"', 'Old \\"one\\"') == text + + +def test_rename_only_info_name(): + ## A "name" deeper in #info, or before #info, isn't the theme's name. + text = '{"x": {"name": "not this"}, "#info": {"extra": {"name": "nor this"}, "creator": "name", "name": "Old"}}' + renamed = json.loads(theme_editor.rename_text(text, "New")) + assert renamed["#info"] == {"extra": {"name": "nor this"}, "creator": "name", "name": "New"} + assert renamed["x"] == {"name": "not this"} + + +@pytest.mark.parametrize("text", ['{"main_menu": {}}', '{"#info": {"creator": "me"}}', '{"#info": "odd"}']) +def test_rename_without_a_name(text): + assert json.loads(theme_editor.rename_text(text, "New"))["#info"]["name"] == "New" + + +################################################################################ +## editor: files +@pytest.fixture +def themes(tmp_path): + root = tmp_path / "themes" + (root / "mine" / "images").mkdir(parents=True) + (root / "mine" / "theme.json").write_text('{\n "#info": {"name": "Mine"}\n}\n') + (root / "mine" / "images" / "bg.png").write_bytes(b"png") + (root / "mine" / "font.ttf").write_bytes(b"ttf") + return serve.Themes(root) + + +@pytest.fixture +def editor(themes, tmp_path): + return theme_editor.ThemeEditor(themes, tmp_path / "work", serve.DEFAULT_THEME_DIR) + + +def test_read(editor): + file = editor.read("mine") + assert file['text'].startswith('{\n "#info"') and file['writable'] and file['can_copy'] + assert file['version'] == theme_editor.text_version(file['text'].encode()) + + +def test_default_theme_is_read_only(editor): + file = editor.read("default_theme") + assert not file['writable'] and file['can_copy'] + with pytest.raises(theme_editor.EditorError) as error: + editor.save("default_theme", file['text'], file['version']) + assert error.value.status == 403 + + +@pytest.mark.parametrize("theme_id", ["nope", "", None, 5, "mine~draft", "../mine"]) +def test_no_such_theme(editor, theme_id): + with pytest.raises(theme_editor.EditorError) as error: + editor.read(theme_id) + assert error.value.status == 404 + + +def test_save(editor, themes): + file = editor.read("mine") + answer = editor.save("mine", '{"#info": {"name": "Saved"}}', file['version']) + + theme_dir = themes.get("mine") + assert json.loads((theme_dir / "theme.json").read_text()) == {"#info": {"name": "Saved"}} + assert (theme_dir / "theme.json.bak").read_text() == file['text'] + assert answer == {'ok': True, 'version': editor.read("mine")['version']} + assert not (theme_dir / "theme.json.tmp").exists() + + +def test_save_unchanged_keeps_the_backup(editor, themes): + file = editor.read("mine") + editor.save("mine", file['text'], file['version']) + assert not (themes.get("mine") / "theme.json.bak").exists() + + +def test_save_changed_on_disk(editor, themes): + file = editor.read("mine") + (themes.get("mine") / "theme.json").write_text('{"#info": {"name": "Theirs"}}') + + with pytest.raises(theme_editor.EditorError) as error: + editor.save("mine", '{"#info": {"name": "Mine"}}', file['version']) + + assert error.value.status == 409 + assert error.value.extra['text'] == '{"#info": {"name": "Theirs"}}' + ## Saved over theirs, given the version they have now. + editor.save("mine", '{"a": 1}', error.value.extra['version']) + assert (themes.get("mine") / "theme.json.bak").read_text() == '{"#info": {"name": "Theirs"}}' + + +@pytest.mark.parametrize("text, status", [("{ nope", 400), ("[1]", 400), ("{}" + " " * theme_editor.MAX_THEME_SIZE, 413)]) +def test_save_refuses(editor, themes, text, status): + file = editor.read("mine") + with pytest.raises(theme_editor.EditorError) as error: + editor.save("mine", text, file['version']) + assert error.value.status == status + assert (themes.get("mine") / "theme.json").read_text() == file['text'] + + +def test_save_read_only_file(editor, themes): + os.chmod(str(themes.get("mine") / "theme.json"), 0o444) + try: + if os.access(str(themes.get("mine") / "theme.json"), os.W_OK): + pytest.skip("running as root") + assert not editor.read("mine")['writable'] + finally: + os.chmod(str(themes.get("mine") / "theme.json"), 0o644) + + +def test_single_theme_folder(tmp_path): + ## --themes naming one theme: it can be changed, but there's no folder to copy into. + (tmp_path / "solo").mkdir() + (tmp_path / "solo" / "theme.json").write_text("{}") + editor = theme_editor.ThemeEditor(serve.Themes(tmp_path / "solo"), tmp_path / "work", serve.DEFAULT_THEME_DIR) + + file = editor.read("solo") + assert file['writable'] and not file['can_copy'] + with pytest.raises(theme_editor.EditorError) as error: + editor.copy("solo", "Other") + assert error.value.status == 403 + + +def test_no_themes_folder(tmp_path): + editor = theme_editor.ThemeEditor(serve.Themes(None), tmp_path / "work", serve.DEFAULT_THEME_DIR) + file = editor.read("default_theme") + assert not file['writable'] and not file['can_copy'] + + +################################################################################ +## Drafts +def test_draft(editor, themes): + answer = editor.draft("mine", '{"#info": {"name": "Draft"}}') + + assert answer == {'ok': True, 'theme': "mine~draft"} + draft_dir = themes.get("mine~draft") + assert json.loads((draft_dir / "theme.json").read_text()) == {"#info": {"name": "Draft"}} + ## The rest of the theme is there, as links: the theme itself is untouched. + assert (draft_dir / "images" / "bg.png").read_bytes() == b"png" + assert (draft_dir / "font.ttf").is_symlink() + assert json.loads((themes.get("mine") / "theme.json").read_text()) == {"#info": {"name": "Mine"}} + ## Drafts aren't listed. + assert "mine~draft" not in themes.all() + + +def test_draft_follows_the_theme(editor, themes): + editor.draft("mine", "{}") + (themes.get("mine") / "font.ttf").unlink() + (themes.get("mine") / "new.png").write_bytes(b"new") + + editor.draft("mine", '{"a": 1}') + + draft_dir = themes.get("mine~draft") + assert not os.path.lexists(str(draft_dir / "font.ttf")) + assert (draft_dir / "new.png").read_bytes() == b"new" + + +def test_draft_version_changes_with_the_text(editor, themes): + editor.draft("mine", '{"a": 1}') + first = serve.version(themes.get("mine~draft"), "en_US") + editor.draft("mine", '{"a": 1}') + assert serve.version(themes.get("mine~draft"), "en_US") == first + + os.utime(str(themes.get("mine~draft") / "theme.json"), (1, 1)) + editor.draft("mine", '{"a": 2}') + assert serve.version(themes.get("mine~draft"), "en_US") != first + + +def test_draft_of_the_default_theme(editor, themes): + editor.draft("default_theme", '{"main_menu": {}}') + draft_dir = themes.get("default_theme~draft") + assert (draft_dir / "buttons.png").is_file() + + +@pytest.mark.parametrize("text", ["{ nope", "[]"]) +def test_draft_bad_json(editor, text): + with pytest.raises(theme_editor.EditorError) as error: + editor.draft("mine", text) + assert error.value.status == 400 and error.value.extra['line'] >= 1 + + +################################################################################ +## Copies +def test_copy(editor, themes): + answer = editor.copy("mine", "My Copy") + + assert answer == {'ok': True, 'theme': "My_Copy"} + copy_dir = themes.get("My_Copy") + assert json.loads((copy_dir / "theme.json").read_text()) == {"#info": {"name": "My Copy"}} + assert (copy_dir / "images" / "bg.png").read_bytes() == b"png" + ## The layout of theme.json is kept. + assert (copy_dir / "theme.json").read_text() == '{\n "#info": {"name": "My Copy"}\n}\n' + + +def test_copy_with_changes(editor, themes): + editor.copy("mine", "Changed", text='{"#info": {"name": "Mine"}, "main_menu": {}}') + assert json.loads((themes.get("Changed") / "theme.json").read_text()) == {"#info": {"name": "Changed"}, "main_menu": {}} + ## The original keeps its own. + assert json.loads((themes.get("mine") / "theme.json").read_text()) == {"#info": {"name": "Mine"}} + + +def test_copy_the_default_theme(editor, themes): + editor.copy("default_theme", "Mine Too") + copy_dir = themes.get("Mine_Too") + data = json.loads((copy_dir / "theme.json").read_text(encoding="utf-8")) + assert data["#info"]["name"] == "Mine Too" and "main_menu" in data + assert (copy_dir / "buttons.png").is_file() + assert editor.read("Mine_Too")['writable'] + + +def test_downloaded_theme(editor, themes): + ## Downloaded from the theme list: the page says an update there replaces the changes. + assert editor.read("mine")['downloaded'] is False + (themes.get("mine") / "theme.md5").write_text("abc") + assert editor.read("mine")['downloaded'] is True + ## A copy is the user's own. + editor.copy("mine", "Own") + assert not (themes.get("Own") / "theme.md5").exists() and editor.read("Own")['downloaded'] is False + + +def test_copy_leaves_backups_behind(editor, themes): + file = editor.read("mine") + editor.save("mine", '{"a": 1}', file['version']) + editor.copy("mine", "Clean") + assert not (themes.get("Clean") / "theme.json.bak").exists() + + +@pytest.mark.parametrize("name, status", [ + ("mine", 409), ("Mine Theme", 200), ("", 400), (" ", 400), ("../evil", 400), ("a/b", 400), + (".hidden", 400), ("x" * 49, 400), (5, 400), (None, 400), ("default_theme", 409), ("x~draft", 400), + ]) +def test_copy_names(editor, name, status): + try: + editor.copy("mine", name) + assert status == 200 + except theme_editor.EditorError as error: + assert error.status == status + + +def test_copy_with_bad_text(editor, themes): + with pytest.raises(theme_editor.EditorError) as error: + editor.copy("mine", "Bad", text="{ nope") + assert error.value.status == 400 + assert themes.get("Bad") is None diff --git a/tests/test_theme_server_http.py b/tests/test_theme_server_http.py index 43c3caa6..ad8cc6dd 100644 --- a/tests/test_theme_server_http.py +++ b/tests/test_theme_server_http.py @@ -70,6 +70,7 @@ def server(locales, themes_dir): pool = FakePool() server = serve.Server(("127.0.0.1", 0), serve.Handler) server.themes, server.pool, server.shots, server.verbose = serve.Themes(themes_dir), pool, pool.shots(), False + server.editor = serve.ThemeEditor(server.themes, themes_dir.parent / "work", serve.DEFAULT_THEME_DIR) ## (Polled often, so shutting it down after each test is quick.) threading.Thread(target=server.serve_forever, kwargs={'poll_interval': 0.02}, daemon=True).start() yield f"http://127.0.0.1:{server.server_address[1]}", pool @@ -438,3 +439,138 @@ def test_export_missing_section(tmp_path): assert serve.export(pool, serve.Themes(None), tmp_path / "out", ["default_theme"], ["640x480"], [], ["en_US"], pool.shots()) == 0 folder = tmp_path / "out" / "default_theme" / "640x480" / "Light Mode" / "en_US" assert not (folder / "main_menu.png").exists() and (folder / "main_menu.json").is_file() + + +################################################################################ +## The Edit view +@pytest.mark.parametrize("path, content_type, cached", [ + ("/static/codemirror.js", "text/javascript", True), + ("/static/codemirror.css", "text/css", True), + ("/static/editor.js", "text/javascript", False), + ]) +def test_static_files(server, path, content_type, cached): + status, body, headers = get(server[0], path) + assert status == 200 and headers["Content-Type"].startswith(content_type) and len(body) > 1000 + assert ("max-age" in headers["Cache-Control"]) == cached + + +@pytest.mark.parametrize("path", ["/static/", "/static/index.html", "/static/codemirror/LICENSE", "/static/codemirror/codemirror.js", "/static/../editor.py"]) +def test_static_only_the_listed_files(server, path): + assert get(server[0], path)[0] == 404 + + +def test_page_loads_the_editor(server): + body = get(server[0], "/")[1].decode() + assert '