├── .gitignore ├── .editorconfig ├── vimhelp ├── static │ ├── favicon-vim.ico │ ├── favicon-neovim.ico │ ├── noscript.css │ ├── theme-native-dark.svg │ ├── theme-native-light.svg │ ├── theme-dark-dark.svg │ ├── theme-dark-light.svg │ ├── theme-light-dark.svg │ ├── theme-light-light.svg │ ├── tom-select.min.css │ └── tom-select.base.min.js ├── templates │ ├── prelude.html │ ├── page.html │ ├── vimhelp.js │ └── vimhelp.css ├── secret.py ├── robots.py ├── http.py ├── cache.py ├── tagsearch.py ├── vimhelp.py ├── dbmodel.py ├── webapp.py ├── assets.py ├── vimh2h.py └── update.py ├── requirements.txt ├── .gcloudignore ├── gunicorn.conf.dev.py ├── cron.yaml ├── app.yaml ├── pyproject.toml ├── LICENSE ├── README.md ├── tasks.py └── scripts └── h2h.py /.gitignore: -------------------------------------------------------------------------------- 1 | TODO 2 | /.venv/ 3 | __pycache__/ 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.py] 4 | max_line_length = 88 5 | -------------------------------------------------------------------------------- /vimhelp/static/favicon-vim.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c4rlo/vimhelp/HEAD/vimhelp/static/favicon-vim.ico -------------------------------------------------------------------------------- /vimhelp/static/favicon-neovim.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c4rlo/vimhelp/HEAD/vimhelp/static/favicon-neovim.ico -------------------------------------------------------------------------------- /vimhelp/static/noscript.css: -------------------------------------------------------------------------------- 1 | .need-js { display: none !important; } 2 | .site.srch ::placeholder { 3 | opacity: 1 !important; 4 | } 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask ~= 3.1 2 | gevent ~= 25.8 3 | geventhttpclient ~= 2.3 4 | google-cloud-ndb ~= 2.3 5 | google-cloud-secret-manager ~= 2.24 6 | google-cloud-tasks ~= 2.19 7 | gunicorn ~= 23.0 8 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | /.editorconfig 2 | /.git 3 | /.gitignore 4 | /.gcloudignore 5 | /.venv 6 | /.ruff_cache 7 | /scripts 8 | /README.md 9 | /LICENSE 10 | /TODO 11 | /tasks.py 12 | /gunicorn.conf.dev.py 13 | __pycache__/ 14 | html/ 15 | -------------------------------------------------------------------------------- /gunicorn.conf.dev.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | 4 | loglevel = "debug" 5 | reload = True 6 | timeout = 15 7 | worker_class = "gevent" 8 | wsgi_app = "vimhelp.webapp:create_app()" 9 | 10 | 11 | def worker_abort(worker): 12 | traceback.print_stack() 13 | -------------------------------------------------------------------------------- /vimhelp/static/theme-native-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vimhelp/static/theme-native-light.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: fetch and process vim docs 3 | url: /enqueue_update?project=vim 4 | schedule: every 31 minutes 5 | - description: fetch and process neovim docs 6 | url: /enqueue_update?project=neovim 7 | schedule: every 32 minutes 8 | - description: clean up old static asset versions 9 | url: /enqueue_clean_assets 10 | schedule: every 33 minutes 11 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python313 2 | 3 | automatic_scaling: 4 | max_instances: 2 5 | max_idle_instances: 1 6 | target_cpu_utilization: 0.9 7 | target_throughput_utilization: 0.9 8 | max_concurrent_requests: 50 9 | min_pending_latency: 500ms 10 | 11 | entrypoint: gunicorn -b :$PORT -k gevent -w 1 'vimhelp.webapp:create_app()' 12 | 13 | inbound_services: 14 | - warmup 15 | 16 | handlers: 17 | - url: /.* 18 | script: auto 19 | secure: always 20 | -------------------------------------------------------------------------------- /vimhelp/static/theme-dark-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vimhelp/static/theme-dark-light.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vimhelp/templates/prelude.html: -------------------------------------------------------------------------------- 1 | {# This "prelude" is the initial portion of each page. It gets rendered 2 | dynamically in response to each request, based on user preferences (currently 3 | only the color theme). #} 4 | 5 | {% if theme %} 6 | 7 | {% else %} 8 | 9 | {% endif %} 10 | 11 | 12 | {% if theme %} 13 | 14 | {% else %} 15 | 16 | {% endif %} 17 | {# rest of the page is in page.html #} 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "vimhelp" 3 | version = "0.1" 4 | dynamic = ["dependencies"] 5 | 6 | [build-system] 7 | requires = ["setuptools", "wheel"] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [tool.setuptools.dynamic] 11 | # Note that Google App Engine requires a requirements.txt file, 12 | # otherwise we'd just specify them directly in here. 13 | dependencies = { file = "requirements.txt" } 14 | 15 | [tool.ruff.lint] 16 | select = ["E", "F", "W", "I002", "N", "UP", "S", "B", "A", "C4", "DTZ", "SIM", "PTH", "PLE", "PLW", "RUF"] 17 | ignore = ["DTZ003", "PLW0603", "SIM102", "SIM108", "UP007"] 18 | 19 | [tool.ruff.lint.per-file-ignores] 20 | "vimhelp/vimh2h.py" = ["E501", "PLW2901", "S704"] 21 | 22 | [tool.ruff.lint.flake8-builtins] 23 | builtins-allowed-modules = ["http"] 24 | -------------------------------------------------------------------------------- /vimhelp/secret.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import threading 4 | 5 | import google.cloud.secretmanager 6 | 7 | 8 | g_secrets = None 9 | g_lock = threading.Lock() 10 | 11 | 12 | def github_token(): 13 | return _get_secrets()["github_token"] 14 | 15 | 16 | def admin_password(): 17 | return _get_secrets()["admin_password"] 18 | 19 | 20 | def _get_secrets(): 21 | with g_lock: 22 | global g_secrets 23 | if g_secrets is None: 24 | client = google.cloud.secretmanager.SecretManagerServiceClient() 25 | cloud_project = os.environ["GOOGLE_CLOUD_PROJECT"] 26 | secret_name = f"projects/{cloud_project}/secrets/secrets/versions/latest" 27 | resp = client.access_secret_version(name=secret_name) 28 | g_secrets = json.loads(resp.payload.data) 29 | return g_secrets 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Carlo Teubner 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vimhelp.org 2 | 3 | This is the code behind the https://vimhelp.org website. It runs on 4 | [Google App Engine](https://cloud.google.com/appengine/). 5 | 6 | To make testing and deploying easier, a `tasks.py` file exists for use 7 | with the [_Invoke_](https://www.pyinvoke.org/) tool (which is similar in 8 | spirit to _Make_). 9 | 10 | ## Generating static pages 11 | 12 | To generate static HTML pages instead of running on Google App Engine: 13 | 14 | - Create a virtualenv. If you have _Invoke_ installed, this is as easy as 15 | `inv venv`. Alternatively: 16 | ``` 17 | python3 -m venv --upgrade-deps .venv 18 | .venv/bin/pip install -r requirements.txt 19 | ``` 20 | - Run the following (replace the `-i` parameter with the Vim documentation 21 | location on your computer): 22 | ``` 23 | scripts/h2h.py -i /usr/share/vim/vim90/doc/ -o html/ 24 | ``` 25 | The script offers a few options; run with `-h` to see what is available. 26 | 27 | ## License 28 | 29 | This code is made freely available under the MIT License (see file LICENSE). 30 | -------------------------------------------------------------------------------- /vimhelp/robots.py: -------------------------------------------------------------------------------- 1 | # Generate 'robots.txt' and 'sitemap.txt' on-the-fly. 2 | 3 | import itertools 4 | 5 | import flask 6 | 7 | from . import dbmodel 8 | 9 | BASE_URLS = { 10 | "vim": "https://vimhelp.org/", 11 | "neovim": "https://neo.vimhelp.org/", 12 | } 13 | 14 | 15 | def handle_robots_txt(): 16 | return flask.Response( 17 | f"Sitemap: {BASE_URLS[flask.g.project]}/sitemap.txt\n", mimetype="text/plain" 18 | ) 19 | 20 | 21 | def handle_sitemap_txt(): 22 | project = flask.g.project 23 | base_url = BASE_URLS[project] 24 | 25 | with dbmodel.ndb_context(): 26 | query = dbmodel.ProcessedFileHead.query( 27 | dbmodel.ProcessedFileHead.project == project 28 | ) 29 | names = set(query.map(lambda key: key.id().split(":")[-1], keys_only=True)) 30 | names.discard("help.txt") 31 | 32 | return flask.Response( 33 | "".join( 34 | itertools.chain( 35 | (f"{base_url}\n",), 36 | (f"{base_url}{name}.html\n" for name in sorted(names)), 37 | ) 38 | ), 39 | mimetype="text/plain", 40 | ) 41 | -------------------------------------------------------------------------------- /vimhelp/static/theme-light-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vimhelp/static/theme-light-light.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vimhelp/http.py: -------------------------------------------------------------------------------- 1 | import json as json_module 2 | import logging 3 | 4 | import geventhttpclient 5 | import geventhttpclient.client 6 | import gevent.ssl 7 | 8 | 9 | class HttpClient: 10 | def __init__(self, concurrency): 11 | self._pool = geventhttpclient.client.HTTPClientPool( 12 | ssl_context_factory=gevent.ssl.create_default_context, 13 | concurrency=concurrency, 14 | ) 15 | 16 | def get(self, url, headers): 17 | try: 18 | url = geventhttpclient.URL(url) 19 | client = self._pool.get_client(url) 20 | response = client.get(url.request_uri, headers=headers) 21 | except Exception as e: 22 | logging.error(e) 23 | raise HttpError(url) from e 24 | return HttpResponse(response, url) 25 | 26 | def post(self, url, json, headers): 27 | try: 28 | url = geventhttpclient.URL(url) 29 | client = self._pool.get_client(url) 30 | response = client.post( 31 | url.request_uri, body=json_module.dumps(json), headers=headers 32 | ) 33 | except Exception as e: 34 | logging.error(e) 35 | raise HttpError(url) from e 36 | return HttpResponse(response, url) 37 | 38 | def close(self): 39 | self._pool.close() 40 | 41 | 42 | class HttpResponse: 43 | def __init__(self, response, url): 44 | self.url = url 45 | self.body = bytes(response.read()) 46 | response.release() 47 | self._response = response 48 | 49 | @property 50 | def status_code(self): 51 | return self._response.status_code 52 | 53 | def header(self, name): 54 | return self._response.get(name) 55 | 56 | 57 | class HttpError(RuntimeError): 58 | def __init__(self, url): 59 | self._url = url 60 | 61 | def __str__(self): 62 | return f"Failed HTTP request for {self._url}" 63 | -------------------------------------------------------------------------------- /vimhelp/cache.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | import gevent 5 | 6 | from .dbmodel import GlobalInfo, ndb_context 7 | 8 | 9 | _REFRESH_INTERVAL_SEC = 120 10 | 11 | 12 | class Cache: 13 | def __init__(self): 14 | self._cache = {} 15 | self._lock = threading.Lock() 16 | 17 | def get(self, project, key): 18 | with self._lock: 19 | return self._cache.get(project, {}).get(key) 20 | 21 | def put(self, project, key, value): 22 | with self._lock: 23 | logging.info("writing %s:%s to inproc cache", project, key) 24 | self._cache.setdefault(project, {})[key] = value 25 | 26 | def clear(self, project): 27 | with self._lock: 28 | if c := self._cache.get(project): 29 | c.clear() 30 | 31 | def start_refresh_loop(self, refresh_callback): 32 | update_times = Cache._get_update_times() 33 | gevent.spawn_later( 34 | _REFRESH_INTERVAL_SEC, self._refresh, update_times, refresh_callback 35 | ) 36 | 37 | def _refresh(self, old_update_times, refresh_callback): 38 | update_times = Cache._get_update_times() 39 | for project, update_time in update_times.items(): 40 | old_update_time = old_update_times.get(project) 41 | if old_update_time is None or update_time > old_update_time: 42 | logging.info( 43 | "project %s was updated (%s < %s), refreshing cache", 44 | project, 45 | old_update_time, 46 | update_time, 47 | ) 48 | self.clear(project) 49 | refresh_callback(project) 50 | else: 51 | logging.info( 52 | "project %s was not updated, not refreshing cache", project 53 | ) 54 | gevent.spawn_later( 55 | _REFRESH_INTERVAL_SEC, self._refresh, update_times, refresh_callback 56 | ) 57 | 58 | @staticmethod 59 | def _get_update_times(): 60 | with ndb_context(): 61 | return {g.key.id(): g.last_update_time for g in GlobalInfo.query()} 62 | -------------------------------------------------------------------------------- /vimhelp/tagsearch.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | 3 | import flask 4 | import werkzeug.exceptions 5 | 6 | from . import dbmodel 7 | 8 | 9 | # There are about 10k tags. To optimize performance, consider: 10 | # - Dealing with 'bytes' not 'str' 11 | # - Giving this module the Cython treatment 12 | 13 | 14 | MAX_RESULTS = 30 15 | CACHE_KEY_ID = "api/tag-items" 16 | 17 | 18 | class TagItem: 19 | def __init__(self, tag, href): 20 | self.tag = tag 21 | self.tag_lower = tag.casefold() 22 | self.href = href 23 | 24 | def __lt__(self, query): 25 | # This is enough for bisect_left to work... 26 | return self.tag < query 27 | 28 | 29 | def handle_tagsearch(cache): 30 | project = flask.g.project 31 | query = flask.request.args.get("q", "") 32 | items = cache.get(project, CACHE_KEY_ID) 33 | if not items: 34 | with dbmodel.ndb_context(): 35 | entity = dbmodel.TagsInfo.get_by_id(project) 36 | if entity is None: 37 | raise werkzeug.exceptions.NotFound() 38 | items = [TagItem(*tag) for tag in entity.tags] 39 | cache.put(project, CACHE_KEY_ID, items) 40 | 41 | results = do_handle_tagsearch(items, query) 42 | return flask.jsonify({"results": results}) 43 | 44 | 45 | def do_handle_tagsearch(items, query): 46 | results = [] 47 | result_set = set() 48 | 49 | is_lower = query == query.casefold() 50 | 51 | def add_result(item): 52 | if item.tag in result_set: 53 | return False 54 | results.append({"id": item.tag, "text": item.tag, "href": item.href}) 55 | result_set.add(item.tag) 56 | return len(results) == MAX_RESULTS 57 | 58 | # Find all tags beginning with query. 59 | i = bisect.bisect_left(items, query) 60 | for item in items[i:]: 61 | if item.tag.startswith(query): 62 | if add_result(item): 63 | return results 64 | else: 65 | break 66 | 67 | # If we didn't find enough, and the query is all-lowercase, add all case-insensitive 68 | # matches. 69 | if is_lower: 70 | for item in items: 71 | if item.tag_lower.startswith(query): 72 | if add_result(item): 73 | return results 74 | 75 | # If we still didn't find enough, additionally find all tags that contain query as a 76 | # substring. 77 | for item in items: 78 | if query in item.tag: 79 | if add_result(item): 80 | return results 81 | 82 | # If we still didn't find enough, and the query is all-lowercase, additionally find 83 | # all tags that contain query as a substring case-insensitively. 84 | if is_lower: 85 | for item in items: 86 | if query in item.tag_lower: 87 | if add_result(item): 88 | return results 89 | 90 | return results 91 | -------------------------------------------------------------------------------- /vimhelp/vimhelp.py: -------------------------------------------------------------------------------- 1 | # Retrieve a help page from the data store, and present to the user 2 | 3 | import logging 4 | from http import HTTPStatus 5 | 6 | import flask 7 | import werkzeug.exceptions 8 | 9 | from google.cloud import ndb 10 | 11 | from . import dbmodel 12 | from . import vimh2h 13 | 14 | 15 | def handle_vimhelp(filename, cache): 16 | req = flask.request 17 | project = flask.g.project 18 | 19 | if filename in ("help.txt", "help"): 20 | return redirect("./") 21 | 22 | if filename == "": 23 | filename = "help.txt" 24 | 25 | if not filename.endswith(".txt") and filename != "tags": 26 | return redirect(f"{filename}.txt.html") 27 | 28 | theme = req.cookies.get("theme") 29 | if theme not in ("light", "dark"): 30 | theme = None 31 | 32 | if entry := cache.get(project, filename): 33 | logging.info("serving '%s:%s' from inproc cache", project, filename) 34 | head, parts = entry 35 | resp = prepare_response(req, head, theme) 36 | return complete_response(resp, head, parts, theme) 37 | 38 | with dbmodel.ndb_context(): 39 | logging.info("serving '%s:%s' from datastore", project, filename) 40 | head = dbmodel.ProcessedFileHead.get_by_id(f"{project}:{filename}") 41 | if head is None: 42 | logging.warning("%s:%s not found in datastore", project, filename) 43 | raise werkzeug.exceptions.NotFound() 44 | resp = prepare_response(req, head, theme) 45 | parts = [] 46 | if resp.status_code != HTTPStatus.NOT_MODIFIED: 47 | parts = get_parts(head) 48 | complete_response(resp, head, parts, theme) 49 | if head.numparts == 1 or parts: 50 | cache.put(project, filename, (head, parts)) 51 | return resp 52 | 53 | 54 | def prepare_response(req, head, theme): 55 | resp = flask.Response(mimetype="text/html") 56 | resp.last_modified = head.modified 57 | resp.cache_control.max_age = 15 * 60 58 | resp.vary.add("Cookie") 59 | resp.set_etag(head.etag.decode() + (theme or "")) 60 | return resp.make_conditional(req) 61 | 62 | 63 | def complete_response(resp, head, parts, theme): 64 | if resp.status_code != HTTPStatus.NOT_MODIFIED: 65 | logging.info( 66 | "writing %d-part response, modified %s", 67 | 1 + len(parts), 68 | resp.last_modified, 69 | ) 70 | prelude = vimh2h.VimH2H.prelude(theme=theme).encode() 71 | resp.data = b"".join((prelude, head.data0, *(p.data for p in parts))) 72 | return resp 73 | 74 | 75 | def redirect(url): 76 | logging.info("redirecting %s to %s", flask.request.path, url) 77 | return flask.redirect(url, HTTPStatus.MOVED_PERMANENTLY) 78 | 79 | 80 | def get_parts(head): 81 | # We could alternatively achieve this via an ancestor query (retrieving the head and 82 | # its parts simultaneously) to give us strong consistency. 83 | if head.numparts == 1: 84 | return [] 85 | logging.info("retrieving %d extra part(s)", head.numparts - 1) 86 | head_id = head.key.id() 87 | keys = [ 88 | ndb.Key("ProcessedFilePart", f"{head_id}:{i}") for i in range(1, head.numparts) 89 | ] 90 | num_tries = 0 91 | while True: 92 | parts = ndb.get_multi(keys) 93 | if all(p.etag == head.etag for p in parts): 94 | return sorted(parts, key=lambda p: p.key.string_id()) 95 | num_tries += 1 96 | if num_tries >= 10: 97 | logging.error("tried too many times, giving up") 98 | raise werkzeug.exceptions.InternalServerError() 99 | logging.warning("got differing etags, retrying") 100 | -------------------------------------------------------------------------------- /vimhelp/templates/page.html: -------------------------------------------------------------------------------- 1 | {# This is the main content of each page; it gets rendered ahead of time. The 2 | first few lines of HTML that are missing from here are are in prelude.html; 3 | those are the only ones that get rendered dynamically with each request. #} 4 | 5 | 6 | {{project.name}}: {{filename}} 7 | 8 | 9 | 10 | {% if mode != "offline" %} 11 | 12 | 13 | {% endif %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% set theme_switcher %} 22 |
23 | 24 |
29 |
30 | {% endset %} 31 | 32 | {% if mode != "offline" and filename == "help.txt" %} 33 |
34 |

{{project.name}} help files

35 | {{theme_switcher}} 36 |
37 |

This is an HTML version of the {{project.name}} help pages{% if version %}, current as of {{project.name}} {{version}}{% endif %}. 38 | They are kept up-to-date automatically 39 | from the {{project.name}} source repository. 40 | {% if project.name == "Vim" %} 41 | Also included is the Vim FAQ, kept up to date from its 42 | GitHub repository. 43 | {% endif %} 44 |

45 | 46 |

Help pages for {{project.other.contrasted_name}} 47 | are also available.

48 | {% endif %} 49 | 50 | {% set sitenavi %} 51 | Quick links: 52 | help overview · 53 | quick reference · 54 | user manual toc · 55 | reference manual toc 56 | {% if project.name == "Vim" %} 57 | · faq 58 | {% endif %} 59 | {% endset %} 60 | 61 | {% if mode != "offline" %} 62 |
63 |
{{sitenavi}}
64 |
65 | 66 |
Go to keyword (shortcut: k)
67 |
68 |
69 | 70 | 71 |
Site search (shortcut: s)
72 |
73 | {% if filename != "help.txt" %} 74 | {{theme_switcher}} 75 | {% endif %} 76 |
77 | {% else %} 78 |

{{sitenavi}}

79 | {% endif %} 80 | 81 |
82 |
83 | {% if sidebar_headings %} 84 | 89 | {% endif %} 90 |
91 |
92 |
 93 | {{content}}
 94 | 
95 |
96 |
97 |

{{sitenavi}}

98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # Use with https://www.pyinvoke.org/ 2 | 3 | from invoke import call, task 4 | 5 | import os 6 | import pathlib 7 | import sys 8 | 9 | 10 | os.chdir(pathlib.Path(__file__).parent) 11 | 12 | 13 | VENV_DIR = pathlib.Path(".venv") 14 | REQ_TXT = pathlib.Path("requirements.txt") 15 | 16 | PRIV_DIR = pathlib.Path("~/private").expanduser() 17 | STAGING_CREDENTIALS = PRIV_DIR / "gcloud-creds/vimhelp-staging-owner.json" 18 | 19 | PROJECT_STAGING = "vimhelp-staging" 20 | PROJECT_PROD = "vimhelp-hrd" 21 | 22 | DEV_ENV = { 23 | "PYTHONDEVMODE": "1", 24 | "PYTHONWARNINGS": ( 25 | "default," 26 | "ignore:unclosed:ResourceWarning:sys," 27 | "ignore:Type google._upb._message:DeprecationWarning:importlib._bootstrap," 28 | "ignore:This process (pid=:DeprecationWarning:gevent.os" 29 | ), 30 | "VIMHELP_ENV": "dev", 31 | "FLASK_DEBUG": "1", 32 | "GOOGLE_CLOUD_PROJECT": PROJECT_STAGING, 33 | "GOOGLE_APPLICATION_CREDENTIALS": str(STAGING_CREDENTIALS), 34 | } 35 | 36 | 37 | @task(help={"lazy": "Only update venv if out-of-date wrt requirements.txt"}) 38 | def venv(c, lazy=False): 39 | """Populate virtualenv.""" 40 | if not VENV_DIR.exists(): 41 | c.run(f"python -m venv --upgrade-deps {VENV_DIR}") 42 | c.run(f"{VENV_DIR}/bin/pip install -U wheel") 43 | print("Created venv.") 44 | lazy = False 45 | if not lazy or REQ_TXT.stat().st_mtime > VENV_DIR.stat().st_mtime: 46 | c.run(f"{VENV_DIR}/bin/pip install -U --upgrade-strategy eager -r {REQ_TXT}") 47 | c.run(f"touch {VENV_DIR}") 48 | print("Updated venv.") 49 | else: 50 | print("venv was already up-to-date.") 51 | 52 | 53 | venv_lazy = call(venv, lazy=True) 54 | 55 | 56 | @task 57 | def lint(c): 58 | """Run linter/formatter (ruff).""" 59 | c.run("ruff check .") 60 | c.run("ruff format --check") 61 | 62 | 63 | @task( 64 | pre=[venv_lazy], 65 | help={ 66 | "gunicorn": "Run using gunicorn instead of 'flask run'", 67 | "tracemalloc": "Run with tracemalloc enabled", 68 | }, 69 | ) 70 | def run(c, gunicorn=False, tracemalloc=False): 71 | """Run app locally against staging database.""" 72 | _ensure_private_mount(c) 73 | if gunicorn: 74 | cmd = f"{VENV_DIR}/bin/gunicorn -c gunicorn.conf.dev.py" 75 | else: 76 | cmd = f"{VENV_DIR}/bin/flask --app vimhelp.webapp --debug run" 77 | if tracemalloc: 78 | env = DEV_ENV | {"PYTHONTRACEMALLOC": "1"} 79 | else: 80 | env = DEV_ENV 81 | c.run(cmd, env=env) 82 | 83 | 84 | @task(pre=[venv_lazy]) 85 | def show_routes(c): 86 | """Show Flask routes.""" 87 | _ensure_private_mount(c) 88 | c.run(f"{VENV_DIR}/bin/flask --app vimhelp.webapp --debug routes", env=DEV_ENV) 89 | 90 | 91 | @task( 92 | pre=[lint], 93 | help={ 94 | "target": "Target environment: 'staging' (default), 'prod', " 95 | "'all' (= staging + prod)", 96 | "cron": "Deploy cron.yaml instead of main app" 97 | }, 98 | ) # fmt: skip 99 | def deploy(c, target="staging", cron=False): 100 | """Deploy app.""" 101 | _ensure_private_mount(c) 102 | if target == "all": 103 | targets = "staging", "prod" 104 | else: 105 | targets = (target,) 106 | for t in targets: 107 | if t == "staging": 108 | cmd = f"gcloud app deploy --quiet --project={PROJECT_STAGING}" 109 | elif t == "prod": 110 | cmd = f"gcloud app deploy --project={PROJECT_PROD}" 111 | else: 112 | sys.exit(f"Invalid target name: '{t}'") 113 | if cron: 114 | cmd += " cron.yaml" 115 | c.run(cmd, pty=True) 116 | 117 | 118 | @task 119 | def clean(c): 120 | """Clean up build artefacts.""" 121 | for d in VENV_DIR, "__pycache__", "vimhelp/__pycache__", ".ruff_cache": 122 | if pathlib.Path(d).exists(): 123 | c.run(f"rm -rf {d}") 124 | 125 | 126 | @task() 127 | def sh(c): 128 | """Interactive shell with virtualenv and datastore available.""" 129 | _ensure_private_mount(c) 130 | with c.prefix(f". {VENV_DIR}/bin/activate"): 131 | c.run(os.getenv("SHELL", "bash"), env=DEV_ENV, pty=True) 132 | print("Exited vimhelp shell") 133 | 134 | 135 | def _ensure_private_mount(c): 136 | if PRIV_DIR.stat().st_dev == PRIV_DIR.parent.stat().st_dev: 137 | c.run(f"sudo systemctl start {PRIV_DIR}", pty=True) 138 | -------------------------------------------------------------------------------- /vimhelp/templates/vimhelp.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | 4 | // Hide sidebar when it wraps 5 | 6 | const onResize = (e) => { 7 | const sidebar = document.getElementById("vh-sidebar"); 8 | const sidebarTop = sidebar.getBoundingClientRect().top; 9 | const contentBottom = document.getElementById("vh-content").getBoundingClientRect().bottom; 10 | if (sidebarTop >= contentBottom - 4) { 11 | sidebar.style.visibility = "hidden"; 12 | sidebar.style.height = "0px"; 13 | } 14 | else { 15 | sidebar.style.visibility = null; 16 | sidebar.style.height = null; 17 | } 18 | }; 19 | addEventListener("resize", onResize); 20 | onResize(); 21 | 22 | 23 | {% if mode != "offline" %} 24 | 25 | // "Go to keyword" entry 26 | 27 | const tagTS = new TomSelect("#vh-select-tag", { 28 | maxItems: 1, 29 | loadThrottle: 250, 30 | valueField: "href", 31 | placeholder: "Go to keyword (type for autocomplete)", 32 | onFocus: () => { 33 | const ts = document.getElementById("vh-select-tag").tomselect; 34 | ts.clear(); 35 | ts.clearOptions(); 36 | }, 37 | shouldLoad: (query) => query.length >= 1, 38 | load: async (query, callback) => { 39 | let url = "/api/tagsearch?q=" + encodeURIComponent(query); 40 | if (document.location.protocol === "file:") { 41 | url = "http://127.0.0.1:5000" + url; 42 | } 43 | const resp = await fetch(url); 44 | const respJson = await resp.json(); 45 | callback(respJson.results); 46 | }, 47 | onChange: (value) => { 48 | if (value) { 49 | window.location = value; 50 | } 51 | } 52 | }); 53 | 54 | document.querySelector(".tag.srch .placeholder").addEventListener("click", (e) => { 55 | tagTS.focus(); 56 | }); 57 | 58 | 59 | // "Site search" entry 60 | 61 | const srchInput = document.getElementById("vh-srch-input"); 62 | srchInput.placeholder = "Site search (opens new DuckDuckGo tab)"; 63 | srchInput.addEventListener("blur", (e) => { 64 | srchInput.value = ""; 65 | }); 66 | document.querySelector(".site.srch .placeholder").addEventListener("click", (e) => { 67 | srchInput.focus(); 68 | }); 69 | 70 | 71 | // Theme switcher 72 | 73 | for (let theme of ["theme-native", "theme-light", "theme-dark"]) { 74 | document.getElementById(theme).addEventListener("click", (e) => { 75 | const [className, meta] = { 76 | "theme-native": [ "", "light dark" ], 77 | "theme-light": [ "light", "only light" ], 78 | "theme-dark": [ "dark", "only dark" ] 79 | }[theme]; 80 | document.documentElement.className = className; 81 | document.querySelector('meta[name="color-scheme"]').content = meta; 82 | 83 | const cookieDomain = location.hostname.replace(/^neo\./, ""); 84 | const cookieExpiry = theme === "theme-native" 85 | ? "Tue, 01 Jan 1970 00:00:00 GMT" // delete cookie 86 | : "Fri, 31 Dec 9999 23:59:59 GMT"; // set "permanent" cookie 87 | document.cookie = 88 | `theme=${className}; Secure; Domain=${cookieDomain}; SameSite=Lax; Path=/; Expires=${cookieExpiry}`; 89 | }); 90 | } 91 | 92 | document.getElementById("theme-current").addEventListener("click", (e) => { 93 | const themeDropdown = document.getElementById("theme-dropdown"); 94 | if (!themeDropdown.style.display) { 95 | // if currently hidden, show it... 96 | themeDropdown.style.display = "revert"; 97 | // ...and prevent the handler on from running, which would hide it again. 98 | e.stopPropagation(); 99 | } 100 | }); 101 | 102 | document.body.addEventListener("click", (e) => { 103 | // hide theme dropdown (vimhelp.css has it as "display: none") 104 | document.getElementById("theme-dropdown").style.display = null; 105 | }); 106 | 107 | // tweak native theme button tooltip 108 | document.getElementById("theme-native").title = "Switch to native theme" + 109 | (matchMedia("(prefers-color-scheme: dark)").matches ? " (which is dark)" : " (which is light)"); 110 | 111 | 112 | // Keyboard shortcuts 113 | // https://github.com/c4rlo/vimhelp/issues/28 114 | 115 | const onKeyDown = (e) => { 116 | if (e.isComposing || e.keyCode === 229) { 117 | // https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event 118 | return; 119 | } 120 | const a = document.activeElement; 121 | if (a && (a.isContentEditable || a.tagName === "INPUT" || a.tagName === "SELECT")) { 122 | return; 123 | } 124 | if (e.key === "k") { 125 | e.preventDefault(); 126 | document.getElementById("vh-select-tag-ts-control").focus(); 127 | } 128 | else if (e.key === "s") { 129 | e.preventDefault(); 130 | document.getElementById("vh-srch-input").focus(); 131 | } 132 | }; 133 | addEventListener("keydown", onKeyDown); 134 | 135 | {% endif %} 136 | -------------------------------------------------------------------------------- /vimhelp/dbmodel.py: -------------------------------------------------------------------------------- 1 | # Definitions of objects stored in Datastore 2 | 3 | from google.cloud import ndb 4 | 5 | 6 | _ndb_client = None 7 | 8 | 9 | def ndb_context(): 10 | global _ndb_client 11 | if _ndb_client is None: 12 | _ndb_client = ndb.Client() 13 | return _ndb_client.context() 14 | 15 | 16 | # There is one of these objects in the datastore, to persist some bits of info that we 17 | # need across update runs; key name is "vim" or "neovim". 18 | class GlobalInfo(ndb.Model): 19 | refs_etag = ndb.BlobProperty() 20 | # HTTP ETag of GraphQL query for latest refs/tags 21 | # (useless in practice since the GitHub GraphQL endpoint seems to not support ETag) 22 | 23 | docdir_etag = ndb.BlobProperty() 24 | # HTTP ETag of the vim repository request for the 'runtime/doc' subdirectory 25 | 26 | master_sha = ndb.TextProperty() 27 | # Git SHA of latest master commit 28 | 29 | vim_version_tag = ndb.TextProperty() 30 | # Git tag of current Vim version 31 | 32 | last_update_time = ndb.DateTimeProperty(indexed=False) 33 | # Time of last changes to generated files 34 | 35 | 36 | # Tags, for use with the "go to tag" feature; key name is "vim" or "neovim". 37 | class TagsInfo(ndb.Model): 38 | tags = ndb.JsonProperty(json_type=list) 39 | # Pairs of vimhelp tag and (site-relative) link. Looks like this: 40 | # [ ["t", "motion.txt.html#t"], ["perl", "if_perl.txt.html#perl"], ... ] 41 | 42 | 43 | # Info related to an unprocessed documentation file from the repository; key name is 44 | # e.g. "vim:help.txt" or "neovim:api.txt" 45 | class RawFileInfo(ndb.Model): 46 | project = ndb.StringProperty(required=True) 47 | # Either "vim" or "neovim", always matches the entity key ID 48 | 49 | git_sha = ndb.BlobProperty() 50 | # 'sha' property returned by GitHub API (not populated for vim_faq.txt) 51 | 52 | etag = ndb.BlobProperty() 53 | # HTTP ETag of the file on github 54 | 55 | 56 | # The actual contents of an unprocessed documentation file from the repository; key name 57 | # is e.g. "vim:faq.txt" or "neovim:help.txt" 58 | class RawFileContent(ndb.Model): 59 | project = ndb.StringProperty(required=True) 60 | # Either "vim" or "neovim", always matches the entity key ID 61 | 62 | data = ndb.BlobProperty(required=True) 63 | # The data 64 | 65 | encoding = ndb.BlobProperty(required=True) 66 | # The encoding, e.g. 'UTF-8' 67 | 68 | 69 | # Info related to a processed (HTMLified) documentation file; key name is e.g. 70 | # "vim:faq.txt" or "neovim:help.txt" 71 | class ProcessedFileHead(ndb.Model): 72 | project = ndb.StringProperty(required=True) 73 | # Either "vim" or "neovim", always matches the entity key ID 74 | 75 | etag = ndb.BlobProperty(required=True) 76 | # HTTP ETag on this server, generated by us as a hash of the contents 77 | 78 | encoding = ndb.BlobProperty(required=True) 79 | # Encoding, always matches the corresponding 'RawFileContent' object 80 | 81 | modified = ndb.DateTimeProperty(indexed=False, auto_now=True) 82 | # Time when this file was generated 83 | 84 | numparts = ndb.IntegerProperty(indexed=False) 85 | # Number of parts; there will be 'numparts - 1' objects of kind 'ProcessedFilePart' 86 | # in the database. Processed files are split up into parts as required by datastore 87 | # blob limitations (currently these can only be up to 1 MiB in size) 88 | 89 | data0 = ndb.BlobProperty(required=True) 90 | # Contents of the first (and possibly only) part 91 | 92 | used_assets = ndb.JsonProperty(json_type=list, indexed=True) 93 | # Names and hashes of assets used by this HTML file. List elements match the 94 | # key names of "Asset" entities. 95 | 96 | 97 | # Part of a processed file; key name is "{project}:{basename}:{partnum}", e.g. 98 | # "neovim:help.txt:1". 99 | # This chunking is necessary because the maximum entity size in the Datastore is 1 MB: 100 | # see https://cloud.google.com/datastore/docs/concepts/limits 101 | # NOTE: vimhelp.py currently relies on the keynames, when sorted lexicographically, 102 | # yielding the correct order; this implies that we must never have a partnum with more 103 | # than one digit. 104 | class ProcessedFilePart(ndb.Model): 105 | data = ndb.BlobProperty(required=True) 106 | # Contents 107 | 108 | etag = ndb.BlobProperty(required=True) 109 | # Same value as corresponding 'ProcessedFileHead.etag'. Used when retrieving the 110 | # 'ProcessedFileHead' and all its 'ProcessedFilePart's to ensure that they were 111 | # retrieved consistently. 112 | 113 | 114 | # Versioned static asset; key name is "{basename}:{hash}", e.g. "vimhelp.js:d34db33f". 115 | class Asset(ndb.Model): 116 | data = ndb.BlobProperty(required=True) 117 | # Contents 118 | 119 | create_time = ndb.DateTimeProperty(required=True, indexed=False, auto_now_add=True) 120 | # Time this entity was created 121 | 122 | unused_time = ndb.DateTimeProperty(indexed=False) 123 | # Time as of which this entity is no longer in use 124 | -------------------------------------------------------------------------------- /scripts/h2h.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env .venv/bin/python3 2 | 3 | # This script is meant to be run from the top-level directory of the 4 | # repository, as 'scripts/h2h.py'. The virtualenv must already exist 5 | # (use "inv venv" to create it). 6 | 7 | import argparse 8 | import os.path 9 | import pathlib 10 | import sys 11 | 12 | import flask 13 | 14 | root_path = pathlib.Path(__file__).parent.parent 15 | 16 | sys.path.append(str(root_path)) 17 | 18 | from vimhelp.vimh2h import VimH2H # noqa: E402 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser(description="Convert Vim help files to HTML") 23 | parser.add_argument( 24 | "--in-dir", 25 | "-i", 26 | required=True, 27 | type=pathlib.Path, 28 | help="Directory of Vim doc files", 29 | ) 30 | parser.add_argument( 31 | "--out-dir", 32 | "-o", 33 | type=pathlib.Path, 34 | help="Output directory (omit for no output)", 35 | ) 36 | parser.add_argument( 37 | "--project", 38 | "-p", 39 | choices=("vim", "neovim"), 40 | default="vim", 41 | help="Vim flavour (default: vim)", 42 | ) 43 | parser.add_argument( 44 | "--web-version", 45 | "-w", 46 | action="store_true", 47 | help="Generate the web version of the files (default: offline version)", 48 | ) 49 | parser.add_argument( 50 | "--theme", 51 | "-t", 52 | choices=("light", "dark"), 53 | help="Color theme (default: OS-native)", 54 | ) 55 | parser.add_argument( 56 | "--no-tags", 57 | "-T", 58 | action="store_true", 59 | help="Ignore any tags file, always recreate tags from scratch", 60 | ) 61 | parser.add_argument( 62 | "--profile", "-P", action="store_true", help="Profile performance" 63 | ) 64 | parser.add_argument( 65 | "basenames", nargs="*", help="List of files to process (default: all)" 66 | ) 67 | args = parser.parse_args() 68 | 69 | app = flask.Flask( 70 | __name__, 71 | root_path=pathlib.Path(__file__).resolve().parent, 72 | template_folder="../vimhelp/templates", 73 | ) 74 | app.jinja_options["trim_blocks"] = True 75 | app.jinja_options["lstrip_blocks"] = True 76 | app.jinja_env.filters["static_path"] = lambda p: p 77 | 78 | with app.app_context(): 79 | if args.profile: 80 | import cProfile 81 | import pstats 82 | 83 | with cProfile.Profile() as pr: 84 | run(args) 85 | stats = pstats.Stats(pr).sort_stats("cumulative") 86 | stats.print_stats() 87 | else: 88 | run(args) 89 | 90 | 91 | def run(args): 92 | if not args.in_dir.is_dir(): 93 | raise RuntimeError(f"{args.in_dir} is not a directory") 94 | 95 | prelude = VimH2H.prelude(theme=args.theme) 96 | 97 | mode = "hybrid" if args.web_version else "offline" 98 | 99 | if not args.no_tags and (tags_file := args.in_dir / "tags").is_file(): 100 | print("Processing tags file...") 101 | h2h = VimH2H(mode=mode, project=args.project, tags=tags_file.read_text()) 102 | faq = args.in_dir / "vim_faq.txt" 103 | if faq.is_file(): 104 | print("Processing FAQ tags...") 105 | h2h.add_tags(faq.name, faq.read_text()) 106 | else: 107 | print("Initializing tags...") 108 | h2h = VimH2H(mode=mode, project=args.project) 109 | for infile in args.in_dir.iterdir(): 110 | if infile.suffix == ".txt": 111 | h2h.add_tags(infile.name, infile.read_text()) 112 | 113 | if args.out_dir is not None: 114 | args.out_dir.mkdir(exist_ok=True) 115 | 116 | for infile in args.in_dir.iterdir(): 117 | if len(args.basenames) != 0 and infile.name not in args.basenames: 118 | continue 119 | if infile.suffix != ".txt" and infile.name != "tags": 120 | print(f"Ignoring {infile}") 121 | continue 122 | content = infile.read_text() 123 | print(f"Processing {infile}...") 124 | html = h2h.to_html(infile.name, content) 125 | if args.out_dir is not None: 126 | with (args.out_dir / f"{infile.name}.html").open("w") as f: 127 | f.write(prelude) 128 | f.write(html) 129 | 130 | if args.out_dir is not None: 131 | print("Symlinking/creating static files...") 132 | static_dir = root_path / "vimhelp" / "static" 133 | static_dir_rel = os.path.relpath(static_dir, args.out_dir) 134 | for target in static_dir.iterdir(): 135 | target_name = target.name 136 | src = args.out_dir / target_name 137 | src.unlink(missing_ok=True) 138 | src.symlink_to(f"{static_dir_rel}/{target_name}") 139 | for name in "vimhelp.css", "vimhelp.js": 140 | content = flask.render_template(name, mode=mode) 141 | (args.out_dir / name).write_text(content) 142 | 143 | print("Done.") 144 | 145 | 146 | main() 147 | -------------------------------------------------------------------------------- /vimhelp/webapp.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | 4 | # gevent.config.track_greenlet_tree = False 5 | gevent.monkey.patch_all() 6 | 7 | import grpc.experimental.gevent # noqa: E402 8 | 9 | grpc.experimental.gevent.init_gevent() 10 | 11 | from http import HTTPStatus # noqa: E402 12 | import flask # noqa: E402 13 | 14 | import logging # noqa: E402 15 | import os # noqa: E402 16 | import pathlib # noqa: E402 17 | 18 | 19 | _CSP = "default-src 'self'" # Content Security Policy 20 | 21 | _URL_PREFIX_REDIRECTS = ( 22 | ( 23 | # From 24 | ( 25 | "http://www.vimhelp.org/", 26 | "https://www.vimhelp.org/", 27 | "http://vimhelp.appspot.com/", 28 | "https://vimhelp.appspot.com/", 29 | "http://vimhelp.org/", 30 | ), 31 | # To 32 | "https://vimhelp.org", 33 | ), 34 | ( 35 | # From 36 | ("http://neo.vimhelp.org/",), 37 | # To 38 | "https://neo.vimhelp.org", 39 | ), 40 | ) 41 | 42 | _WARMUP_PATH = "/_ah/warmup" 43 | 44 | g_is_dev = False 45 | 46 | 47 | def create_app() -> flask.Flask: 48 | from . import assets 49 | from . import cache 50 | from . import robots 51 | from . import tagsearch 52 | from . import vimhelp 53 | from . import update 54 | 55 | package_path = pathlib.Path(__file__).resolve().parent 56 | 57 | logging.basicConfig(level=logging.INFO) 58 | 59 | cache_ = cache.Cache() 60 | 61 | app = flask.Flask("vimhelp", root_path=package_path, static_folder=None) 62 | 63 | app.jinja_options["trim_blocks"] = True 64 | app.jinja_options["lstrip_blocks"] = True 65 | app.jinja_env.filters["static_path"] = assets.static_path 66 | 67 | global g_is_dev 68 | g_is_dev = os.environ.get("VIMHELP_ENV") == "dev" 69 | if not g_is_dev: 70 | app.config["PREFERRED_URL_SCHEME"] = "https" 71 | 72 | assets.init(app) 73 | 74 | app.add_url_rule( 75 | "/clean_assets", view_func=assets.CleanAssetsHandler.as_view("clean_assets") 76 | ) 77 | app.add_url_rule( 78 | "/enqueue_clean_assets", view_func=assets.handle_enqueue_clean_assets 79 | ) 80 | 81 | bp = flask.Blueprint("bp", "vimhelp", root_path=package_path) 82 | 83 | @bp.route("/.html") 84 | @bp.route("/", defaults={"filename": ""}) 85 | def vimhelp_filename(filename): 86 | return vimhelp.handle_vimhelp(filename, cache_) 87 | 88 | @bp.route("/s//") 89 | def static_filename(hash_, filename): 90 | return assets.handle_static(filename, hash_) 91 | 92 | @bp.route("/api/tagsearch") 93 | def vimhelp_tagsearch(): 94 | return tagsearch.handle_tagsearch(cache_) 95 | 96 | @bp.route("/favicon.ico") 97 | def favicon(): 98 | return assets.handle_static( 99 | f"favicon-{flask.g.project}.ico", None, immutable=False 100 | ) 101 | 102 | bp.add_url_rule("/robots.txt", view_func=robots.handle_robots_txt) 103 | bp.add_url_rule("/sitemap.txt", view_func=robots.handle_sitemap_txt) 104 | bp.add_url_rule("/update", view_func=update.UpdateHandler.as_view("update")) 105 | bp.add_url_rule("/enqueue_update", view_func=update.handle_enqueue_update) 106 | 107 | app.register_blueprint(bp, name="vim") 108 | 109 | if g_is_dev: 110 | app.register_blueprint(bp, name="neovim", url_prefix="/neovim") 111 | # On production, neovim uses its own "neovim." subdomain, which is handled below in 112 | # the before_request handler. 113 | 114 | def do_warmup(project): 115 | logging.info("doing warmup request for %s", project) 116 | with app.test_request_context(): 117 | flask.g.project = project 118 | vimhelp.handle_vimhelp("", cache_) 119 | vimhelp.handle_vimhelp("options.txt", cache_) 120 | tagsearch.handle_tagsearch(cache_) 121 | 122 | @app.route(_WARMUP_PATH) 123 | def warmup(): 124 | for project in ("vim", "neovim"): 125 | do_warmup(project) 126 | return flask.Response() 127 | 128 | @app.before_request 129 | def before(): 130 | req = flask.request 131 | 132 | # Redirect away from legacy / non-HTTPS URL prefixes 133 | if req.path not in (_WARMUP_PATH, "/update"): 134 | for redir_from, redir_to in _URL_PREFIX_REDIRECTS: 135 | if req.url_root in redir_from: 136 | path = req.full_path if req.query_string else req.path 137 | new_url = redir_to + req.root_path + path 138 | logging.info("redirecting %s to %s", req.url, new_url) 139 | return flask.redirect(new_url, HTTPStatus.MOVED_PERMANENTLY) 140 | 141 | # Flask's subdomain/host matching doesn't seem compatible with having multiple 142 | # valid server names (in particular, App Engine calls the /enqueue_update 143 | # endpoint with something other than vimhelp.org), so we do it this way. 144 | flask.g.project = ( 145 | "neovim" 146 | if req.blueprint == "neovim" or req.host.startswith("neo.") 147 | else "vim" 148 | ) 149 | 150 | app.after_request(_add_default_headers) 151 | 152 | gevent.spawn(cache_.start_refresh_loop, do_warmup) 153 | 154 | logging.info("app initialised") 155 | 156 | return app 157 | 158 | 159 | def _add_default_headers(response: flask.Response) -> flask.Response: 160 | h = response.headers 161 | h.setdefault("Content-Security-Policy", _CSP) 162 | # The following is needed for local dev scenarios where one is accessing an HTML 163 | # file on disk ('file://' protocol) and wants it to be able to consume the tagsearch 164 | # API. 165 | if g_is_dev: 166 | h.setdefault("Access-Control-Allow-Origin", "*") 167 | return response 168 | -------------------------------------------------------------------------------- /vimhelp/assets.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import hashlib 4 | import importlib.resources 5 | import itertools 6 | import logging 7 | import mimetypes 8 | import os 9 | import threading 10 | 11 | import flask 12 | import flask.views 13 | import google.cloud.ndb 14 | import werkzeug.exceptions 15 | 16 | from . import dbmodel 17 | from . import secret 18 | 19 | 20 | _DELETE_GRACE_PERIOD = datetime.timedelta(days=1) 21 | 22 | _curr_assets = {} # basename -> (hash, content) 23 | 24 | _assets_written_lock = threading.Lock() 25 | _assets_written = False 26 | 27 | 28 | def init(app): 29 | for asset in _asset_resources().iterdir(): 30 | _add_curr_asset(asset.name, asset.read_bytes()) 31 | 32 | with app.app_context(): 33 | for name in "vimhelp.css", "vimhelp.js": 34 | content = flask.render_template(name, mode="online").encode() 35 | _add_curr_asset(name, content) 36 | 37 | 38 | def handle_static(name, hash_, immutable=True): 39 | if hash_ is None: 40 | hash_ = _curr_asset_hash(name) 41 | if asset := _get_asset(name, hash_): 42 | mimetype, _ = mimetypes.guess_type(name) 43 | logging.info("Serving static asset %s/%s (%s)", hash_, name, mimetype) 44 | resp = flask.Response(asset, mimetype=mimetype) 45 | if immutable: 46 | resp.cache_control.immutable = True 47 | resp.cache_control.max_age = 3600 * 24 * 365 48 | else: 49 | resp.cache_control.max_age = 3600 * 24 50 | return resp 51 | logging.warning("Static asset %s/%s not found", hash_, name) 52 | raise werkzeug.exceptions.NotFound() 53 | 54 | 55 | def static_path(name): 56 | return f"/s/{_curr_asset_hash(name)}/{name}" 57 | 58 | 59 | def curr_asset_ids(): 60 | return [f"{name}:{hash_}" for name, (hash_, _) in _curr_assets.items()] 61 | 62 | 63 | def ensure_curr_assets_in_db(): 64 | # Caller must already be in an ndb context 65 | if not _do_ensure_curr_assets_in_db(): 66 | logging.info("No new assets to write to datastore") 67 | 68 | 69 | def clean_unused_assets(): 70 | logging.info("Cleaning up unused assets") 71 | now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) 72 | recent = now - _DELETE_GRACE_PERIOD 73 | with dbmodel.ndb_context(): 74 | all_asset_ids = {key.id() for key in dbmodel.Asset.query().iter(keys_only=True)} 75 | pfh_query = dbmodel.ProcessedFileHead.query(projection=["used_assets"]) 76 | used_asset_ids = set(itertools.chain(*(pfh.used_assets for pfh in pfh_query))) 77 | unused_asset_ids = all_asset_ids - used_asset_ids 78 | unused_asset_keys = [google.cloud.ndb.Key("Asset", i) for i in unused_asset_ids] 79 | unused_assets = google.cloud.ndb.get_multi(unused_asset_keys) 80 | to_delete = [] 81 | to_put = [] 82 | for asset in unused_assets: 83 | if asset.create_time >= recent: 84 | continue 85 | if asset.unused_time is not None: 86 | if asset.unused_time < recent: 87 | to_delete.append(asset.key) 88 | else: 89 | asset.unused_time = now 90 | to_put.append(asset) 91 | if len(to_delete) == 0 and len(to_put) == 0: 92 | logging.info("No assets need cleaning") 93 | return 94 | logging.info( 95 | "Deleting %d old asset(s) and marking %d asset(s) as unused", 96 | len(to_delete), 97 | len(to_put), 98 | ) 99 | google.cloud.ndb.delete_multi(to_delete) 100 | google.cloud.ndb.put_multi(to_put) 101 | 102 | 103 | def _get_asset(name, hash_): 104 | if a := _curr_assets.get(name): 105 | curr_hash, curr_content = a 106 | if curr_hash == hash_: 107 | return curr_content 108 | with dbmodel.ndb_context(): 109 | if asset := dbmodel.Asset.get_by_id(f"{name}:{hash_}"): 110 | return asset.data 111 | return None 112 | 113 | 114 | def _add_curr_asset(name, content): 115 | hash_ = base64.urlsafe_b64encode(hashlib.sha256(content).digest()[:12]).decode() 116 | _curr_assets[name] = hash_, content 117 | 118 | 119 | def _do_ensure_curr_assets_in_db(): 120 | with _assets_written_lock: 121 | global _assets_written 122 | if _assets_written: 123 | return False 124 | _assets_written = True 125 | 126 | existing_ids = {key.id() for key in dbmodel.Asset.query().iter(keys_only=True)} 127 | new_assets = [ 128 | dbmodel.Asset(id=f"{name}:{hash_}", data=content) 129 | for name, (hash_, content) in _curr_assets.items() 130 | if f"{name}:{hash_}" not in existing_ids 131 | ] 132 | if len(new_assets) > 0: 133 | logging.info("Writing %d current asset(s) to datastore", len(new_assets)) 134 | google.cloud.ndb.put_multi(new_assets) 135 | return True 136 | return False 137 | 138 | 139 | def _curr_asset_hash(name): 140 | return _curr_assets[name][0] 141 | 142 | 143 | def _asset_resources(): 144 | return importlib.resources.files("vimhelp.static") 145 | 146 | 147 | class CleanAssetsHandler(flask.views.MethodView): 148 | def get(self): 149 | if ( 150 | os.environ.get("VIMHELP_ENV") != "dev" 151 | and secret.admin_password().encode() not in flask.request.query_string 152 | ): 153 | raise werkzeug.exceptions.Forbidden() 154 | clean_unused_assets() 155 | return "Success." 156 | 157 | def post(self): 158 | # https://cloud.google.com/tasks/docs/creating-appengine-handlers#reading_app_engine_task_request_headers 159 | if "X-AppEngine-QueueName" not in flask.request.headers: 160 | raise werkzeug.exceptions.Forbidden() 161 | clean_unused_assets() 162 | return flask.Response() 163 | 164 | 165 | def handle_enqueue_clean_assets(): 166 | req = flask.request 167 | 168 | is_cron = req.headers.get("X-Appengine-Cron") == "true" 169 | 170 | # https://cloud.google.com/appengine/docs/standard/scheduling-jobs-with-cron-yaml#securing_urls_for_cron 171 | if ( 172 | not is_cron 173 | and os.environ.get("VIMHELP_ENV") != "dev" 174 | and secret.admin_password().encode() not in req.query_string 175 | ): 176 | raise werkzeug.exceptions.Forbidden() 177 | 178 | logging.info("Enqueueing assets clean") 179 | 180 | client = google.cloud.tasks.CloudTasksClient() 181 | queue_name = client.queue_path( 182 | os.environ["GOOGLE_CLOUD_PROJECT"], "us-central1", "update2" 183 | ) 184 | task = { 185 | "app_engine_http_request": { 186 | "http_method": "POST", 187 | "relative_uri": "/clean_assets", 188 | } 189 | } 190 | response = client.create_task(parent=queue_name, task=task) 191 | logging.info("Task %s enqueued, ETA %s", response.name, response.schedule_time) 192 | 193 | if is_cron: 194 | return flask.Response() 195 | else: 196 | return "Successfully enqueued assets clean task." 197 | -------------------------------------------------------------------------------- /vimhelp/static/tom-select.min.css: -------------------------------------------------------------------------------- 1 | .ts-control{border:1px solid #d0d0d0;border-radius:3px;box-shadow:none;box-sizing:border-box;display:flex;flex-wrap:wrap;overflow:hidden;padding:8px;position:relative;width:100%;z-index:1}.ts-wrapper.multi.has-items .ts-control{padding:6px 8px 3px}.full .ts-control{background-color:#fff}.disabled .ts-control,.disabled .ts-control *{cursor:default!important}.focus .ts-control{box-shadow:none}.ts-control>*{display:inline-block;vertical-align:initial}.ts-wrapper.multi .ts-control>div{background:#f2f2f2;border:0 solid #d0d0d0;color:#303030;cursor:pointer;margin:0 3px 3px 0;padding:2px 6px}.ts-wrapper.multi .ts-control>div.active{background:#e8e8e8;border:0 solid #cacaca;color:#303030}.ts-wrapper.multi.disabled .ts-control>div,.ts-wrapper.multi.disabled .ts-control>div.active{background:#fff;border:0 solid #fff;color:#7d7d7d}.ts-control>input{background:none!important;border:0!important;box-shadow:none!important;display:inline-block!important;flex:1 1 auto;line-height:inherit!important;margin:0!important;max-height:none!important;max-width:100%!important;min-height:0!important;min-width:7rem;padding:0!important;text-indent:0!important;-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.ts-control>input::-ms-clear{display:none}.ts-control>input:focus{outline:none!important}.has-items .ts-control>input{margin:0 4px!important}.ts-control.rtl{text-align:right}.ts-control.rtl.single .ts-control:after{left:15px;right:auto}.ts-control.rtl .ts-control>input{margin:0 4px 0 -2px!important}.disabled .ts-control{background-color:#fafafa;opacity:.5}.input-hidden .ts-control>input{left:-10000px;opacity:0;position:absolute}.ts-dropdown{background:#fff;border:1px solid #d0d0d0;border-radius:0 0 3px 3px;border-top:0;box-shadow:0 1px 3px rgba(0,0,0,.1);box-sizing:border-box;left:0;margin:.25rem 0 0;position:absolute;top:100%;width:100%;z-index:10}.ts-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.ts-dropdown [data-selectable] .highlight{background:rgba(125,168,208,.2);border-radius:1px}.ts-dropdown .create,.ts-dropdown .no-results,.ts-dropdown .optgroup-header,.ts-dropdown .option{padding:5px 8px}.ts-dropdown .option,.ts-dropdown [data-disabled],.ts-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.ts-dropdown [data-selectable].option{cursor:pointer;opacity:1}.ts-dropdown .optgroup:first-child .optgroup-header{border-top:0}.ts-dropdown .optgroup-header{background:#fff;color:#303030;cursor:default}.ts-dropdown .active{background-color:#f5fafd;color:#495c68}.ts-dropdown .active.create{color:#495c68}.ts-dropdown .create{color:rgba(48,48,48,.5)}.ts-dropdown .spinner{display:inline-block;height:30px;margin:5px 8px;width:30px}.ts-dropdown .spinner:after{animation:lds-dual-ring 1.2s linear infinite;border-color:#d0d0d0 transparent;border-radius:50%;border-style:solid;border-width:5px;content:" ";display:block;height:24px;margin:3px;width:24px}@keyframes lds-dual-ring{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.ts-dropdown-content{max-height:200px;overflow:hidden auto;scroll-behavior:smooth}.ts-wrapper.plugin-drag_drop .ts-dragging{color:transparent!important}.ts-wrapper.plugin-drag_drop .ts-dragging>*{visibility:hidden!important}.plugin-checkbox_options:not(.rtl) .option input{margin-right:.5rem}.plugin-checkbox_options.rtl .option input{margin-left:.5rem}.plugin-clear_button{--ts-pr-clear-button:1em}.plugin-clear_button .clear-button{background:transparent!important;cursor:pointer;margin-right:0!important;opacity:0;position:absolute;right:2px;top:50%;transform:translateY(-50%);transition:opacity .5s}.plugin-clear_button.form-select .clear-button,.plugin-clear_button.single .clear-button{right:max(var(--ts-pr-caret),8px)}.plugin-clear_button.focus.has-items .clear-button,.plugin-clear_button:not(.disabled):hover.has-items .clear-button{opacity:1}.ts-wrapper .dropdown-header{background:color-mix(#fff,#d0d0d0,85%);border-bottom:1px solid #d0d0d0;border-radius:3px 3px 0 0;padding:10px 8px;position:relative}.ts-wrapper .dropdown-header-close{color:#303030;font-size:20px!important;line-height:20px;margin-top:-12px;opacity:.4;position:absolute;right:8px;top:50%}.ts-wrapper .dropdown-header-close:hover{color:#000}.plugin-dropdown_input.focus.dropdown-active .ts-control{border:1px solid #d0d0d0;box-shadow:none}.plugin-dropdown_input .dropdown-input{background:transparent;border:solid #d0d0d0;border-width:0 0 1px;box-shadow:none;display:block;padding:8px;width:100%}.plugin-dropdown_input .items-placeholder{border:0!important;box-shadow:none!important;width:100%}.plugin-dropdown_input.dropdown-active .items-placeholder,.plugin-dropdown_input.has-items .items-placeholder{display:none!important}.ts-wrapper.plugin-input_autogrow.has-items .ts-control>input{min-width:0}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input{flex:none;min-width:4px}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input::-ms-input-placeholder{color:transparent}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input::placeholder{color:transparent}.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content{display:flex}.ts-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0;flex-basis:0;flex-grow:1;min-width:0}.ts-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0}.ts-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.ts-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0}.ts-wrapper.plugin-remove_button .item{align-items:center;display:inline-flex}.ts-wrapper.plugin-remove_button .item .remove{border-radius:0 2px 2px 0;box-sizing:border-box;color:inherit;display:inline-block;padding:0 6px;text-decoration:none;vertical-align:middle}.ts-wrapper.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,.05)}.ts-wrapper.plugin-remove_button.disabled .item .remove:hover{background:none}.ts-wrapper.plugin-remove_button .remove-single{font-size:23px;position:absolute;right:0;top:0}.ts-wrapper.plugin-remove_button:not(.rtl) .item{padding-right:0!important}.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove{border-left:1px solid #d0d0d0;margin-left:6px}.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove{border-left-color:#cacaca}.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove{border-left-color:#fff}.ts-wrapper.plugin-remove_button.rtl .item{padding-left:0!important}.ts-wrapper.plugin-remove_button.rtl .item .remove{border-right:1px solid #d0d0d0;margin-right:6px}.ts-wrapper.plugin-remove_button.rtl .item.active .remove{border-right-color:#cacaca}.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove{border-right-color:#fff}:root{--ts-pr-clear-button:0px;--ts-pr-caret:0px;--ts-pr-min:.75rem}.ts-wrapper.single .ts-control,.ts-wrapper.single .ts-control input{cursor:pointer}.ts-control:not(.rtl){padding-right:max(var(--ts-pr-min),var(--ts-pr-clear-button) + var(--ts-pr-caret))!important}.ts-control.rtl{padding-left:max(var(--ts-pr-min),var(--ts-pr-clear-button) + var(--ts-pr-caret))!important}.ts-wrapper{position:relative}.ts-control,.ts-control input,.ts-dropdown{color:#303030;font-family:inherit;font-size:13px;line-height:18px}.ts-control,.ts-wrapper.single.input-active .ts-control{background:#fff;cursor:text}.ts-hidden-accessible{border:0!important;clip:rect(0 0 0 0)!important;-webkit-clip-path:inset(50%)!important;clip-path:inset(50%)!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:1px!important} 2 | /*# sourceMappingURL=tom-select.min.css.map */ -------------------------------------------------------------------------------- /vimhelp/templates/vimhelp.css: -------------------------------------------------------------------------------- 1 | *, ::before, ::after { box-sizing: border-box; } 2 | 3 | :root { 4 | /* gruvbox-light-hard colours */ 5 | --bg0: #f9f5d7; 6 | --bg1: #ebdbb2; 7 | --bg2: #d5c4a1; 8 | --bg4: #a89984; 9 | --fg0: #282828; 10 | --fg1: #3c3836; 11 | --fg4: #7c6f64; 12 | --blue: #076678; 13 | --blue-lighter: #288799; /* 13% lighter than blue above */ 14 | --green: #79740e; 15 | --aqua: #427b58; 16 | --orange: #af3a03; 17 | --gray: #928374; 18 | --yellow: #b57614; 19 | --red: #9d0006; 20 | 21 | --font-serif: georgia, palatino, serif; 22 | --font-mono: monospace, monospace; 23 | } 24 | 25 | #theme-current { background-image: url({{"theme-native-light.svg"|static_path}}); } 26 | #theme-native { background-image: url({{"theme-native-light.svg"|static_path}}); } 27 | #theme-light { background-image: url({{"theme-light-light.svg"|static_path}}); } 28 | #theme-dark { background-image: url({{"theme-dark-light.svg"|static_path}}); } 29 | 30 | :root.dark { 31 | /* gruvbox-dark-hard colours */ 32 | --bg0: #1d2021; 33 | --bg1: #3c3836; 34 | --bg2: #504945; 35 | --bg4: #7c6f64; 36 | --fg0: #fbf1c7; 37 | --fg1: #ebdbb2; 38 | --fg4: #a89984; 39 | --blue: #83a598; 40 | --blue-lighter: #a4c6b9; /* 13% lighter than blue above */ 41 | --green: #b8bb26; 42 | --aqua: #8ec07c; 43 | --orange: #fe8019; 44 | --gray: #928374; 45 | --yellow: #fabd2f; 46 | --red: #fb4934; 47 | } 48 | 49 | :root.dark #theme-current { background-image: url({{"theme-dark-dark.svg"|static_path}}); } 50 | :root.dark #theme-native { background-image: url({{"theme-native-dark.svg"|static_path}}); } 51 | :root.dark #theme-light { background-image: url({{"theme-light-dark.svg"|static_path}}); } 52 | :root.dark #theme-dark { background-image: url({{"theme-dark-dark.svg"|static_path}}); } 53 | 54 | @media (prefers-color-scheme: dark) { 55 | :root { 56 | /* gruvbox-dark-hard colours */ 57 | --bg0: #1d2021; 58 | --bg1: #3c3836; 59 | --bg2: #504945; 60 | --bg4: #7c6f64; 61 | --fg0: #fbf1c7; 62 | --fg1: #ebdbb2; 63 | --fg4: #a89984; 64 | --blue: #83a598; 65 | --blue-lighter: #a4c6b9; /* 13% lighter than blue above */ 66 | --green: #b8bb26; 67 | --aqua: #8ec07c; 68 | --orange: #fe8019; 69 | --gray: #928374; 70 | --yellow: #fabd2f; 71 | --red: #fb4934; 72 | } 73 | #theme-current { background-image: url({{"theme-native-dark.svg"|static_path}}); } 74 | #theme-native { background-image: url({{"theme-native-dark.svg"|static_path}}); } 75 | #theme-light { background-image: url({{"theme-light-dark.svg"|static_path}}); } 76 | #theme-dark { background-image: url({{"theme-dark-dark.svg"|static_path}}); } 77 | :root.light { 78 | /* gruvbox-light-hard colours */ 79 | --bg0: #f9f5d7; 80 | --bg1: #ebdbb2; 81 | --bg2: #d5c4a1; 82 | --bg4: #a89984; 83 | --fg0: #282828; 84 | --fg1: #3c3836; 85 | --fg4: #7c6f64; 86 | --blue: #076678; 87 | --blue-lighter: #288799; /* 13% lighter than blue above */ 88 | --green: #79740e; 89 | --aqua: #427b58; 90 | --orange: #af3a03; 91 | --gray: #928374; 92 | --yellow: #b57614; 93 | --red: #9d0006; 94 | } 95 | :root.light #theme-current { background-image: url({{"theme-light-light.svg"|static_path}}); } 96 | :root.light #theme-native { background-image: url({{"theme-native-light.svg"|static_path}}); } 97 | :root.light #theme-light { background-image: url({{"theme-light-light.svg"|static_path}}); } 98 | :root.light #theme-dark { background-image: url({{"theme-dark-light.svg"|static_path}}); } 99 | } 100 | 101 | @media (pointer: none), (pointer: coarse) { 102 | .not-mobile { display: none; } 103 | } 104 | 105 | html { 106 | line-height: 1.15; 107 | font-family: var(--font-serif); 108 | background-color: var(--bg0); 109 | color: var(--fg1); 110 | } 111 | 112 | /* title + theme switcher */ 113 | #title-cont { display: flex; align-items: start; justify-content: space-between; } 114 | #title-cont > #theme-switcher { margin-top: 8pt; margin-right: 8pt; } 115 | #theme-switcher button { 116 | display: flex; 117 | align-items: center; 118 | padding: 5px 5px 5px 30px; 119 | min-height: 25px; 120 | background-size: 25px 25px; 121 | background-position: 2px 2px; 122 | background-repeat: no-repeat; 123 | background-color: var(--bg1); 124 | color: inherit; 125 | font: inherit; 126 | border: none; 127 | cursor: pointer; 128 | } 129 | button#theme-current { font-weight: bold; } 130 | #theme-dropdown { display: none; position: relative; } 131 | #theme-dropdown > ul { position: absolute; width: 100%; margin: 0; padding: 0; } 132 | #theme-dropdown > ul > li { list-style-type: none; width: 100%; } 133 | #theme-dropdown > ul > li > button { width: 100%; } 134 | 135 | /* top bar: quick links and search boxes */ 136 | .bar { margin-bottom: 2em; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5em; } 137 | .ql { flex: 1 20 auto; } 138 | 139 | /* "Go to keyword" search box */ 140 | .ts-control { 141 | color: var(--fg1); 142 | background-color: var(--bg1) !important; 143 | border: 1px solid var(--bg2); 144 | border-radius: 4px; 145 | height: 28px; 146 | font-family: var(--font-mono); 147 | font-size: 1em; 148 | line-height: 28px; 149 | cursor: revert !important; 150 | padding-top: 0px; 151 | padding-left: 8px; 152 | padding-right: 20px; 153 | } 154 | .ts-control:focus-within { 155 | border: 1px solid var(--bg4); 156 | } 157 | .ts-wrapper.dropdown-active .ts-control { 158 | border-bottom: none; 159 | border-radius: 4px 4px 0px 0px; 160 | } 161 | .ts-control > input { 162 | color: var(--fg1); 163 | font-size: 1em; 164 | font-family: var(--font-mono); 165 | cursor: revert !important; 166 | } 167 | .ts-dropdown { 168 | font-family: var(--font-mono); 169 | font-size: 1em; 170 | color: var(--fg1); 171 | background-color: var(--bg1); 172 | border: 1px solid var(--bg4); 173 | border-radius: 0px 0px 4px 4px; 174 | margin: 0; 175 | } 176 | .ts-dropdown .active { 177 | color: var(--fg1); 178 | background-color: var(--bg4); 179 | } 180 | .ts-dropdown .no-results { 181 | font-family: var(--font-serif); 182 | } 183 | 184 | /* Site search */ 185 | .srch { 186 | position: relative; 187 | flex: 20 20 25ch; 188 | max-width: 45ch; 189 | overflow: hidden; 190 | } 191 | .srch:focus-within { overflow: revert; } 192 | .srch ::placeholder { 193 | color: var(--fg4); 194 | font-family: var(--font-serif); 195 | opacity: 0; 196 | } 197 | .srch input:focus::placeholder { 198 | opacity: 1; 199 | } 200 | #vh-srch-input { 201 | background-color: var(--bg1); 202 | color: var(--fg1); 203 | border: 1px solid var(--bg2); 204 | border-radius: 4px; 205 | height: 28px; 206 | width: 100%; 207 | line-height: 28px; 208 | padding-top: 3px; 209 | padding-left: 8px; 210 | padding-right: 20px; 211 | font-family: var(--font-serif); 212 | font-size: 1em; 213 | } 214 | #vh-srch-input:focus { 215 | border: 1px solid var(--bg4); 216 | outline: none; 217 | } 218 | 219 | .placeholder { 220 | position: absolute; 221 | display: flex; 222 | align-items: center; 223 | z-index: 2; 224 | left: 9px; 225 | top: 2px; 226 | bottom: 0; 227 | cursor: text; 228 | color: var(--fg4); 229 | white-space: nowrap; 230 | } 231 | .placeholder kbd { 232 | position: relative; 233 | top: -1px; 234 | margin-right: 1px; 235 | padding: 2px 4px; 236 | font-family: var(--font-mono); 237 | font-size: 0.7em; 238 | font-weight: bold; 239 | background-color: #fff3; 240 | border: 1px solid var(--bg2); 241 | border-radius: 3px; 242 | box-shadow: 0 2px 1px #0004, 0 1px #fff3 inset; 243 | } 244 | .srch:focus-within .placeholder { 245 | display: none; 246 | } 247 | 248 | /* main = sidebar + content */ 249 | main { 250 | display: flex; 251 | flex-wrap: wrap-reverse; 252 | align-items: start; 253 | } 254 | 255 | /* Nav sidebar */ 256 | #vh-sidebar { 257 | position: sticky; 258 | top: 10px; 259 | width: min-content; 260 | flex: auto; 261 | display: none; 262 | justify-content: center; 263 | } 264 | #vh-sidebar > ul { 265 | font-family: var(--font-serif); 266 | padding: 1em 1em 1em 1.8em; 267 | margin-right: 0.8em; 268 | background-color: var(--bg1); 269 | border-radius: 15px; 270 | } 271 | 272 | /* Vim help content */ 273 | #vh-content pre { 274 | font-family: var(--font-mono); 275 | width: 80ch; 276 | } 277 | 278 | @media (min-width: 900px) { 279 | main { 280 | justify-content: end; 281 | } 282 | #vh-sidebar { 283 | display: flex; 284 | } 285 | #vh-content pre { 286 | margin-right: calc(50vw - 40ch); 287 | } 288 | } 289 | 290 | /* standard links (also includes ) */ 291 | a:where(:link, :visited) { color: var(--blue); } 292 | a:where(:active, :hover) { color: var(--blue-lighter); } 293 | 294 | /* de-emphasized links */ 295 | a.d { color: var(--fg1); } 296 | a.d:link, a.d:visited { text-decoration: underline var(--bg4); } 297 | a.d:active, a.d:hover { text-decoration: underline var(--fg1); } 298 | 299 | /* title */ 300 | .i { color: var(--blue); } 301 | 302 | /* tag; external url */ 303 | .t, .u { color: var(--green); font-style: italic; } 304 | 305 | /* header */ 306 | .h { color: var(--aqua); } 307 | 308 | /* keystroke; special (used for various) */ 309 | .k, .s { color: var(--orange); } 310 | 311 | /* example */ 312 | .e { color: var(--gray); font-style: italic; } 313 | 314 | /* note */ 315 | .n { color: var(--fg0); font-style: italic; font-weight: bold; } 316 | 317 | /* option */ 318 | .o { color: var(--yellow); } 319 | 320 | /* section */ 321 | .c { color: var(--red); } 322 | 323 | footer { font-size: 85%; padding: 1em 0; } 324 | -------------------------------------------------------------------------------- /vimhelp/vimh2h.py: -------------------------------------------------------------------------------- 1 | # Translates Vim documentation to HTML 2 | 3 | import functools 4 | import html 5 | import re 6 | import urllib.parse 7 | 8 | import flask 9 | import markupsafe 10 | 11 | 12 | class VimProject: 13 | name = "Vim" 14 | contrasted_name = "the original Vim" 15 | url = "https://www.vim.org/" 16 | vimdoc_site = "vimhelp.org" 17 | doc_src_url = "https://github.com/vim/vim/tree/master/runtime/doc" 18 | favicon = "favicon-vim.ico" 19 | favicon_notice = "favicon is based on http://amnoid.de/tmp/vim_solidbright_512.png and is used with permission by its author" 20 | 21 | 22 | class NeovimProject: 23 | name = "Neovim" 24 | contrasted_name = "Neovim" 25 | url = "https://neovim.io/" 26 | vimdoc_site = "neo.vimhelp.org" 27 | doc_src_url = "https://github.com/neovim/neovim/tree/master/runtime/doc" 28 | favicon = "favicon-neovim.ico" 29 | favicon_notice = "favicon taken from https://neovim.io/favicon.ico, which is licensed under CC-BY-3.0: https://creativecommons.org/licenses/by/3.0/" 30 | 31 | 32 | VimProject.other = NeovimProject 33 | NeovimProject.other = VimProject 34 | 35 | NeovimProject.local_additions = """\ 36 | matchit.txt Extended "%" matching 37 | """ 38 | 39 | # fmt: off 40 | VimProject.local_additions = NeovimProject.local_additions + """\ 41 | editorconfig.txt EditorConfig plugin for vim. 42 | vim_faq.txt Frequently Asked Questions 43 | """ 44 | # fmt: on 45 | 46 | 47 | PROJECTS = {"vim": VimProject, "neovim": NeovimProject} 48 | 49 | RE_TAGLINE = re.compile(r"(\S+)\s+(\S+)") 50 | 51 | PAT_WORDCHAR = "[!#-)+-{}~\xc0-\xff]" 52 | 53 | PAT_HEADER = r"(^.*~$)" 54 | PAT_GRAPHIC = r"(^.* `$)" 55 | PAT_PIPEWORD = r"(?|Break|PageUp|PageDown|Insert|Del|.)?)" 60 | PAT_SPECIAL = ( 61 | r"(<(?:[-a-zA-Z0-9_]+|[SCM]-.)>|\{.+?}|" 62 | r"\[(?:range|line|count|offset|\+?cmd|[-+]?num|\+\+opt|" 63 | r"arg|arguments|ident|addr|group)]|vim9\[cmd]|" 64 | r"(?<=\s)\[[-a-z^A-Z0-9_]{2,}])" 65 | ) 66 | PAT_TITLE = r"(Vim version [0-9.a-z]+|N?VIM REFERENCE.*)" 67 | PAT_NOTE = ( 68 | r"((? \t]+[a-zA-Z0-9/])' 71 | PAT_WORD = ( 72 | r"((?(?:vim|lua)?$") 101 | RE_EG_END = re.compile(r"[^ \t]") 102 | RE_SECTION = re.compile( 103 | r"(?!NOTE$|UTF-8\.$|VALID\.$|OLE\.$|CTRL-|\.\.\.$)" 104 | r"([A-Z.][-A-Z0-9 .,()_?']*?)\s*(?:\s\*|$)" 105 | ) 106 | RE_STARTAG = re.compile(r'\*([^ \t"*]+)\*(?:\s|$)') 107 | RE_LOCAL_ADD = re.compile(r".*\s\*local-additions\*$") 108 | 109 | 110 | class Link: 111 | def __init__(self, filename, htmlfilename, tag): 112 | self.filename = filename 113 | self._htmlfilename = htmlfilename 114 | if tag == "help-tags" and filename == "tags": 115 | self._tag_quoted = None 116 | else: 117 | self._tag_quoted = urllib.parse.quote_plus(tag) 118 | self._tag_escaped = _html_escape(tag) 119 | self._cssclass = "d" 120 | if m := RE_LINKWORD.match(tag): 121 | opt, ctrl, special = m.groups() 122 | if opt is not None: 123 | self._cssclass = "o" 124 | elif ctrl is not None: 125 | self._cssclass = "k" 126 | elif special is not None: 127 | self._cssclass = "s" 128 | 129 | @functools.cache # noqa: B019 130 | def href(self, is_same_doc): 131 | if self._tag_quoted is None: 132 | return self._htmlfilename 133 | doc = "" if is_same_doc else self._htmlfilename 134 | return f"{doc}#{self._tag_quoted}" 135 | 136 | @functools.cache # noqa: B019 137 | def html(self, is_pipe, is_same_doc): 138 | cssclass = "l" if is_pipe else self._cssclass 139 | return ( 140 | f'' 141 | f"{self._tag_escaped}" 142 | ) 143 | 144 | 145 | # Concealed chars in Vim still count towards hard tabs' spacing calculations even though 146 | # they are hidden. We need to count them so we can insert that many spaces before we 147 | # encounter a hard tab to nudge it to the right position. This class helps with that. 148 | class TabFixer: 149 | def __init__(self): 150 | self._accum_concealed_chars = 0 151 | 152 | def incr_concealed_chars(self, num): 153 | self._accum_concealed_chars += num 154 | 155 | def fix_tabs(self, text): 156 | if self._accum_concealed_chars > 0: 157 | if (tab_index := text.find("\t")) != -1: 158 | adjustment = " " * self._accum_concealed_chars 159 | self._accum_concealed_chars = 0 160 | return f"{text[:tab_index]}{adjustment}{text[tab_index:]}" 161 | return text 162 | 163 | 164 | class VimH2H: 165 | def __init__(self, mode="online", project="vim", version=None, tags=None): 166 | self._mode = mode 167 | self._project = PROJECTS[project] 168 | self._version = version 169 | self._urls = {} 170 | if tags is not None: 171 | for line in RE_NEWLINE.split(tags): 172 | if m := RE_TAGLINE.match(line): 173 | tag, filename = m.group(1, 2) 174 | self.do_add_tag(filename, tag) 175 | if self._project == VimProject: 176 | self._urls["help-tags"] = Link("tags", "tags.html", "help-tags") 177 | 178 | def __del__(self): 179 | Link.href.cache_clear() 180 | Link.html.cache_clear() 181 | 182 | def add_tags(self, filename, contents): 183 | in_example = False 184 | for line in RE_NEWLINE.split(contents): 185 | if in_example: 186 | if RE_EG_END.match(line): 187 | in_example = False 188 | else: 189 | continue 190 | for anchor in RE_STARTAG.finditer(line): 191 | tag = anchor.group(1) 192 | self.do_add_tag(filename, tag) 193 | if RE_EG_START.match(line): 194 | in_example = True 195 | 196 | def do_add_tag(self, filename, tag): 197 | self._urls[tag] = Link(filename, self.htmlfilename(filename), tag) 198 | 199 | def sorted_tag_href_pairs(self): 200 | result = [ 201 | (tag, link.href(is_same_doc=False)) for tag, link in self._urls.items() 202 | ] 203 | result.sort() 204 | return result 205 | 206 | def maplink(self, tag, curr_filename, css_class=None): 207 | links = self._urls.get(tag) 208 | if links is not None: 209 | is_pipe = css_class == "l" 210 | is_same_doc = links.filename == curr_filename 211 | return links.html(is_pipe, is_same_doc) 212 | elif css_class is not None: 213 | return f'{_html_escape(tag)}' 214 | else: 215 | return _html_escape(tag) 216 | 217 | def synthesize_tag(self, curr_filename, text): 218 | def xform(c): 219 | if c.isalnum(): 220 | return c.lower() 221 | elif c in " ,.?!'\"": 222 | return "-" 223 | else: 224 | return "" 225 | 226 | base_tag = "_" + "".join(map(xform, text[:25])) 227 | tag = base_tag 228 | i = 0 229 | while True: 230 | link = self._urls.get(tag) 231 | if link is None or link.filename != curr_filename: 232 | return tag 233 | tag = f"{base_tag}_{i}" 234 | i += 1 235 | 236 | def htmlfilename(self, name): 237 | if name == "help.txt" and self._mode == "online": 238 | return "./" 239 | else: 240 | return name + ".html" 241 | 242 | @staticmethod 243 | def prelude(theme): 244 | return flask.render_template("prelude.html", theme=theme) 245 | 246 | def to_html(self, filename, contents): 247 | is_help_txt = filename == "help.txt" 248 | lines = [line.rstrip("\r\n") for line in RE_NEWLINE.split(contents)] 249 | 250 | out = [] 251 | sidebar_headings = [] 252 | sidebar_lvl = 2 253 | in_example = False 254 | for idx, line in enumerate(lines): 255 | prev_line = "" if idx == 0 else lines[idx - 1] 256 | if prev_line == "" and idx > 1: 257 | prev_line = lines[idx - 2] 258 | 259 | if in_example: 260 | if RE_EG_END.match(line): 261 | in_example = False 262 | if line[0] == "<": 263 | line = line[1:] 264 | else: 265 | out.extend(('', _html_escape(line), "\n")) 266 | continue 267 | 268 | if RE_HRULE.match(line): 269 | out.extend(('', _html_escape(line), "\n")) 270 | continue 271 | 272 | if m := RE_EG_START.match(line): 273 | in_example = True 274 | line = m.group(1) or "" 275 | 276 | heading = None 277 | skip_to_col = None 278 | if m := RE_SECTION.match(line): 279 | heading = m.group(1) 280 | heading_lvl = 2 281 | out.extend(('', heading, "")) 282 | skip_to_col = m.end(1) 283 | elif RE_HRULE1.match(prev_line) and (m := RE_HEADING.match(line)): 284 | heading = m.group(1) 285 | heading_lvl = 1 286 | 287 | span_opened = False 288 | if heading is not None and sidebar_lvl >= heading_lvl: 289 | if sidebar_lvl > heading_lvl: 290 | sidebar_lvl = heading_lvl 291 | sidebar_headings = [] 292 | if m := RE_STARTAG.search(line): 293 | tag = m.group(1) 294 | else: 295 | tag = self.synthesize_tag(filename, heading) 296 | out.append(f'') 297 | span_opened = True 298 | tag_escaped = urllib.parse.quote_plus(tag) 299 | sidebar_headings.append( 300 | markupsafe.Markup( 301 | f'{_html_escape(heading)}' 302 | ) 303 | ) 304 | 305 | if skip_to_col is not None: 306 | line = line[skip_to_col:] 307 | 308 | is_local_additions = is_help_txt and RE_LOCAL_ADD.match(line) 309 | lastpos = 0 310 | 311 | tab_fixer = TabFixer() 312 | 313 | for match in RE_TAGWORD.finditer(line): 314 | pos = match.start() 315 | if pos > lastpos: 316 | out.append(_html_escape(tab_fixer.fix_tabs(line[lastpos:pos]))) 317 | lastpos = match.end() 318 | # fmt: off 319 | (header, graphic, pipeword, starword, command, opt, ctrl, special, 320 | title, note, url, word) = match.groups() 321 | # fmt: on 322 | if pipeword is not None: 323 | out.append(self.maplink(pipeword, filename, "l")) 324 | tab_fixer.incr_concealed_chars(2) 325 | elif starword is not None: 326 | out.extend( 327 | ( 328 | '', 331 | _html_escape(starword), 332 | "", 333 | ) 334 | ) 335 | tab_fixer.incr_concealed_chars(2) 336 | elif command is not None: 337 | out.extend(('', _html_escape(command), "")) 338 | tab_fixer.incr_concealed_chars(2) 339 | elif opt is not None: 340 | out.append(self.maplink(opt, filename, "o")) 341 | elif ctrl is not None: 342 | out.append(self.maplink(ctrl, filename, "k")) 343 | elif special is not None: 344 | out.append(self.maplink(special, filename, "s")) 345 | elif title is not None: 346 | out.extend(('', _html_escape(title), "")) 347 | elif note is not None: 348 | out.extend(('', _html_escape(note), "")) 349 | elif header is not None: 350 | out.extend( 351 | ('', _html_escape(header[:-1]), "") 352 | ) 353 | elif graphic is not None: 354 | out.append(_html_escape(graphic[:-2])) 355 | elif url is not None: 356 | out.extend( 357 | ('', _html_escape(url), "") 358 | ) 359 | elif word is not None: 360 | out.append(self.maplink(word, filename)) 361 | if lastpos < len(line): 362 | out.append(_html_escape(tab_fixer.fix_tabs(line[lastpos:]))) 363 | if span_opened: 364 | out.append("") 365 | out.append("\n") 366 | 367 | if is_local_additions: 368 | out.append(self._project.local_additions) 369 | 370 | return flask.render_template( 371 | "page.html", 372 | mode=self._mode, 373 | project=self._project, 374 | version=self._version, 375 | filename=filename, 376 | helptxt=self.htmlfilename("help.txt"), 377 | content=markupsafe.Markup("".join(out)), 378 | sidebar_headings=sidebar_headings, 379 | ) 380 | 381 | 382 | @functools.cache 383 | def _html_escape(s): 384 | return html.escape(s, quote=False) 385 | -------------------------------------------------------------------------------- /vimhelp/update.py: -------------------------------------------------------------------------------- 1 | # Regularly scheduled update: check which files need updating and translate them 2 | 3 | import base64 4 | import datetime 5 | import hashlib 6 | import itertools 7 | import json 8 | import logging 9 | import os 10 | import re 11 | from http import HTTPStatus 12 | 13 | import flask 14 | import flask.views 15 | import gevent 16 | import gevent.pool 17 | import werkzeug.exceptions 18 | 19 | import google.cloud.ndb 20 | import google.cloud.tasks 21 | 22 | from .dbmodel import ( 23 | GlobalInfo, 24 | ProcessedFileHead, 25 | ProcessedFilePart, 26 | RawFileContent, 27 | RawFileInfo, 28 | TagsInfo, 29 | ndb_context, 30 | ) 31 | from .http import HttpClient, HttpResponse 32 | from . import assets 33 | from . import secret 34 | from . import vimh2h 35 | 36 | 37 | # Once we have consumed about ten minutes of CPU time, Google will throw us a 38 | # DeadlineExceededError and our script terminates. Therefore, we must be careful with 39 | # the order of operations, to ensure that after this has happened, the next scheduled 40 | # run of the script can pick up where the previous one was interrupted. Although in 41 | # practice, it takes about 30 seconds, so it's unlikely to be an issue. 42 | 43 | # Number of concurrent (in the gevent sense) workers. Avoid setting this too high, else 44 | # there is risk of running out of memory on our puny worker node. 45 | CONCURRENCY = 5 46 | 47 | # Max size in bytes of processed file part to store in a single entity in the datastore. 48 | # Note that datastore entities have a maximum size of just under 1 MiB. 49 | MAX_DB_PART_LEN = 995000 50 | 51 | TAGS_NAME = "tags" 52 | HELP_NAME = "help.txt" 53 | FAQ_NAME = "vim_faq.txt" 54 | MATCHIT_NAME = "matchit.txt" 55 | EDITORCONFIG_NAME = "editorconfig.txt" 56 | EXTRA_NAMES = TAGS_NAME, MATCHIT_NAME, EDITORCONFIG_NAME 57 | 58 | DOC_ITEM_RE = re.compile(r"(?:[-\w]+\.txt|tags)$") 59 | VERSION_TAG_RE = re.compile(r"v?(\d[\w.+-]+)$") 60 | 61 | GITHUB_DOWNLOAD_URL_BASE = "https://raw.githubusercontent.com/" 62 | GITHUB_GRAPHQL_API_URL = "https://api.github.com/graphql" 63 | 64 | FAQ_BASE_URL = "https://raw.githubusercontent.com/chrisbra/vim_faq/master/doc/" 65 | 66 | GITHUB_GRAPHQL_QUERIES = { 67 | "GetRefs": """ 68 | query GetRefs($org: String!, $repo: String!) { 69 | repository(owner: $org, name: $repo) { 70 | defaultBranchRef { 71 | target { 72 | oid 73 | } 74 | } 75 | refs(refPrefix: "refs/tags/", 76 | orderBy: {field: TAG_COMMIT_DATE, direction: DESC}, 77 | first: 5) { 78 | nodes { 79 | name 80 | } 81 | } 82 | } 83 | } 84 | """, 85 | "GetDirs": """ 86 | query GetDirs($org: String!, $repo: String!, 87 | $expr1: String!, $expr2: String!, $expr3: String!) { 88 | repository(owner: $org, name: $repo) { 89 | dir1: object(expression: $expr1) { 90 | ...treeEntries 91 | } 92 | dir2: object(expression: $expr2) { 93 | ...treeEntries 94 | } 95 | dir3: object(expression: $expr3) { 96 | ...treeEntries 97 | } 98 | } 99 | } 100 | fragment treeEntries on GitObject { 101 | ...on Tree { 102 | entries { 103 | type 104 | name 105 | oid 106 | } 107 | } 108 | } 109 | """, 110 | } 111 | 112 | 113 | class UpdateHandler(flask.views.MethodView): 114 | def post(self): 115 | # We get an HTTP POST request if the request came programmatically via Cloud 116 | # Tasks. 117 | self._run(flask.request.data) 118 | return flask.Response() 119 | 120 | def get(self): 121 | # We get an HTTP GET request if the request was generated by the user, by 122 | # entering the URL in their browser. 123 | self._run(flask.request.query_string) 124 | return "Success." 125 | 126 | def _run(self, request_data): 127 | req = flask.request 128 | 129 | # https://cloud.google.com/tasks/docs/creating-appengine-handlers#reading_app_engine_task_request_headers 130 | if ( 131 | "X-AppEngine-QueueName" not in req.headers 132 | and os.environ.get("VIMHELP_ENV") != "dev" 133 | and secret.admin_password().encode() not in request_data 134 | ): 135 | raise werkzeug.exceptions.Forbidden() 136 | 137 | is_force = b"force" in request_data 138 | 139 | if b"project=vim" in request_data: 140 | self._project = "vim" 141 | elif b"project=neovim" in request_data: 142 | self._project = "neovim" 143 | else: 144 | self._project = flask.g.project 145 | 146 | logging.info( 147 | "Starting %supdate for %s", "forced " if is_force else "", self._project 148 | ) 149 | 150 | self._app = flask.current_app._get_current_object() 151 | self._http_client = HttpClient(CONCURRENCY) 152 | 153 | try: 154 | self._greenlet_pool = gevent.pool.Pool(size=CONCURRENCY) 155 | 156 | with ndb_context(): 157 | self._g = self._init_g(wipe=is_force) 158 | self._g_dict_pre = self._g.to_dict() 159 | self._had_exception = False 160 | if self._project == "vim": 161 | self._do_update_vim(no_rfi=is_force) 162 | elif self._project == "neovim": 163 | self._do_update_neovim(no_rfi=is_force) 164 | else: 165 | raise RuntimeError(f"unknown project '{self._project}'") 166 | 167 | if not self._had_exception and self._g_dict_pre != self._g.to_dict(): 168 | self._g.put() 169 | logging.info( 170 | "Finished %s update, updated global info", self._project 171 | ) 172 | else: 173 | logging.info( 174 | "Finished %s update, global info not updated", self._project 175 | ) 176 | 177 | self._greenlet_pool.join() 178 | finally: 179 | self._http_client.close() 180 | 181 | def _init_g(self, wipe): 182 | """Initialize 'self._g' (GlobalInfo)""" 183 | g = GlobalInfo.get_by_id(self._project) 184 | 185 | if wipe: 186 | logging.info( 187 | "Deleting %s global info and raw files from Datastore", self._project 188 | ) 189 | greenlets = [ 190 | self._spawn(wipe_db, RawFileContent, self._project), 191 | self._spawn(wipe_db, RawFileInfo, self._project), 192 | ] 193 | if g: 194 | greenlets.append(self._spawn(g.key.delete)) 195 | g = None 196 | gevent.joinall(greenlets) 197 | 198 | if not g: 199 | g = GlobalInfo(id=self._project, last_update_time=utcnow()) 200 | 201 | gs = ", ".join(f"{n} = {getattr(g, n)}" for n in g._properties.keys()) # noqa: SIM118 202 | logging.info("%s global info: %s", self._project, gs) 203 | 204 | return g 205 | 206 | def _do_update_vim(self, no_rfi): 207 | old_vim_version_tag = self._g.vim_version_tag 208 | old_master_sha = self._g.master_sha 209 | 210 | # Kick off retrieval of master branch SHA and vim version from GitHub 211 | get_git_refs_greenlet = self._spawn(self._get_git_refs) 212 | 213 | # Kick off retrieval of all RawFileInfo entities from the Datastore 214 | rfi_greenlet = self._spawn(self._get_all_rfi, no_rfi) 215 | 216 | # Check whether the master branch is updated, and whether we have a new vim 217 | # version 218 | get_git_refs_greenlet.get() 219 | is_master_updated = self._g.master_sha != old_master_sha 220 | is_new_vim_version = self._g.vim_version_tag != old_vim_version_tag 221 | 222 | if is_master_updated: 223 | # Kick off retrieval of doc dirs listing in GitHub. This is against 224 | # the 'master' branch, since the docs often get updated after the tagged 225 | # commits that introduce the relevant changes. 226 | docdir_greenlet = self._spawn(self._list_docs_dir, self._g.master_sha) 227 | else: 228 | # No need to list doc dir if nothing changed 229 | docdir_greenlet = None 230 | 231 | # Put all RawFileInfo entities into a map 232 | self._rfi_map = rfi_greenlet.get() 233 | 234 | # Kick off FAQ download (this also writes the raw file to the datastore, if 235 | # modified) 236 | faq_greenlet = self._spawn(self._get_file, FAQ_NAME, "http") 237 | 238 | # Iterate over doc dirs listing (which also updates the items in 239 | # 'self._rfi_map') and collect list of new/modified files 240 | if docdir_greenlet is None: 241 | logging.info("No need to get new doc dir listing") 242 | updated_file_names = set() 243 | else: 244 | updated_file_names = { 245 | name for name, is_modified in docdir_greenlet.get() if is_modified 246 | } 247 | 248 | # Check FAQ download result 249 | faq_result = faq_greenlet.get() 250 | if not faq_result.is_modified: 251 | if len(updated_file_names) == 0 and not is_new_vim_version: 252 | logging.info("Nothing to do") 253 | return 254 | faq_result = None 255 | faq_greenlet = self._spawn(self._get_file, FAQ_NAME, "db") 256 | 257 | # Write current versions of static assets (vimhelp.js etc) to datastore 258 | assets_greenlet = self._spawn(assets.ensure_curr_assets_in_db) 259 | 260 | # Get extra files from GitHub or datastore, depending on whether they were 261 | # changed 262 | extra_greenlets = {} 263 | for name in EXTRA_NAMES: 264 | if name in updated_file_names: 265 | updated_file_names.remove(name) 266 | sources = "http,db" 267 | else: 268 | sources = "db" 269 | extra_greenlets[name] = self._spawn(self._get_file, name, sources) 270 | 271 | extra_results = {name: extra_greenlets[name].get() for name in EXTRA_NAMES} 272 | extra_results[FAQ_NAME] = faq_result or faq_greenlet.get() 273 | tags_result = extra_results[TAGS_NAME] 274 | 275 | logging.info("Beginning vimhelp-to-HTML translations") 276 | 277 | self._g.last_update_time = utcnow() 278 | 279 | # Construct the vimhelp-to-html translator, providing it the tags file content, 280 | # and adding on the extra files from which to source more tags 281 | self._h2h = vimh2h.VimH2H( 282 | mode="online", 283 | project="vim", 284 | version=version_from_tag(self._g.vim_version_tag), 285 | tags=tags_result.content.decode(), 286 | ) 287 | for name, result in extra_results.items(): 288 | if name != TAGS_NAME: 289 | self._h2h.add_tags(name, result.content.decode()) 290 | 291 | # Ensure all assets are in the datastore by now 292 | assets_greenlet.get() 293 | 294 | greenlets = [] 295 | 296 | def track_spawn(f, *args, **kwargs): 297 | greenlets.append(self._spawn(f, *args, **kwargs)) 298 | 299 | # Save tags JSON if we may have updated tags 300 | if any(result.is_modified for result in extra_results.values()): 301 | track_spawn(self._save_tags_json) 302 | 303 | # Translate each extra file if either it, or the tags file, was modified 304 | # (a changed tags file can lead to different outgoing links) 305 | for name, result in extra_results.items(): 306 | if result.is_modified or tags_result.is_modified: 307 | track_spawn(self._translate, name, result.content) 308 | 309 | # If we found a new vim version, ensure we translate help.txt, since we're 310 | # displaying the current vim version in the rendered help.txt.html 311 | if is_new_vim_version: 312 | track_spawn( 313 | self._get_file_and_translate, HELP_NAME, translate_if_not_modified=True 314 | ) 315 | updated_file_names.discard(HELP_NAME) 316 | 317 | # Translate all other modified files, after retrieving them from GitHub or 318 | # datastore (this also writes the raw file info to the datastore, if modified) 319 | # TODO: theoretically we should re-translate all files (whether in 320 | # updated_file_names or not) if the tags file was modified 321 | for name in updated_file_names: 322 | track_spawn( 323 | self._get_file_and_translate, name, translate_if_not_modified=False 324 | ) 325 | 326 | logging.info("Waiting for everything to finish") 327 | 328 | self._join_greenlets(greenlets) 329 | 330 | def _do_update_neovim(self, no_rfi): 331 | # Check whether we have a new Neovim version 332 | old_vim_version_tag = self._g.vim_version_tag 333 | self._get_git_refs() 334 | if self._g.vim_version_tag == old_vim_version_tag: 335 | logging.info("Nothing to do") 336 | return 337 | 338 | # Write current versions of static assets (vimhelp.js etc) to datastore 339 | assets_greenlet = self._spawn(assets.ensure_curr_assets_in_db) 340 | 341 | # Kick off retrieval of all RawFileInfo entities from the Datastore 342 | rfi_greenlet = self._spawn(self._get_all_rfi, no_rfi) 343 | 344 | # Kick off retrieval of doc dirs listing in GitHub for the current 345 | # version. 346 | docdir_greenlet = self._spawn(self._list_docs_dir, self._g.vim_version_tag) 347 | 348 | # Put all RawFileInfo entities into a map 349 | self._rfi_map = rfi_greenlet.get() 350 | 351 | self._g.last_update_time = utcnow() 352 | 353 | self._h2h = vimh2h.VimH2H( 354 | mode="online", 355 | project="neovim", 356 | version=version_from_tag(self._g.vim_version_tag), 357 | ) 358 | 359 | # Iterate over doc dirs listing (which also updates the items in 360 | # 'self._rfi_map'), kicking off retrieval of files and addition of help tags to 361 | # 'self._h2h'; file retrieval also includes writing the raw file to the 362 | # datastore if modified 363 | all_file_names = set() 364 | for name, is_modified in docdir_greenlet.get(): 365 | all_file_names.add(name) 366 | sources = "http,db" if is_modified else "db" 367 | self._spawn(self._get_file_and_add_tags, name, sources) 368 | 369 | # Wait for all tag additions to complete 370 | self._greenlet_pool.join(raise_error=True) 371 | 372 | # Save tags JSON 373 | greenlets = [self._spawn(self._save_tags_json)] 374 | 375 | # Ensure all assets are in the datastore by now 376 | assets_greenlet.get() 377 | 378 | logging.info("Beginning vimhelp-to-HTML conversions") 379 | 380 | # Kick off processing of all files, reading file contents from the Datastore, 381 | # where we just saved them all 382 | for name in all_file_names: 383 | greenlets.append( 384 | self._spawn( 385 | self._get_file_and_translate, 386 | name, 387 | translate_if_not_modified=True, 388 | sources="db", 389 | ) 390 | ) 391 | 392 | self._join_greenlets(greenlets) 393 | 394 | def _get_git_refs(self): 395 | """ 396 | Populate 'master_sha', 'vim_version_tag, 'refs_etag' members of 'self._g' 397 | (GlobalInfo) 398 | """ 399 | # Hmm, the GitHub GraphQL API does not seem to actually support ETag: 400 | # https://github.com/github-community/community/discussions/10799 401 | r = self._github_graphql_request( 402 | "GetRefs", 403 | variables={"org": self._project, "repo": self._project}, 404 | etag=self._g.refs_etag, 405 | ) 406 | if r.status_code == HTTPStatus.OK: 407 | etag_str = r.header("ETag") 408 | etag = etag_str.encode() if etag_str is not None else None 409 | if etag == self._g.refs_etag: 410 | logging.info( 411 | "%s GetRefs query ETag unchanged (%s)", self._project, etag 412 | ) 413 | else: 414 | logging.info( 415 | "%s GetRefs query ETag changed: %s -> %s", 416 | self._project, 417 | self._g.refs_etag, 418 | etag, 419 | ) 420 | self._g.refs_etag = etag 421 | resp = json.loads(r.body)["data"]["repository"] 422 | latest_sha = resp["defaultBranchRef"]["target"]["oid"] 423 | if latest_sha == self._g.master_sha: 424 | logging.info("%s master SHA unchanged (%s)", self._project, latest_sha) 425 | else: 426 | logging.info( 427 | "%s master SHA changed: %s -> %s", 428 | self._project, 429 | self._g.master_sha, 430 | latest_sha, 431 | ) 432 | self._g.master_sha = latest_sha 433 | tags = resp["refs"]["nodes"] 434 | latest_version_tag = None 435 | for tag in tags: 436 | tag_name = tag["name"] 437 | if VERSION_TAG_RE.match(tag_name): 438 | latest_version_tag = tag_name 439 | break 440 | if latest_version_tag == self._g.vim_version_tag: 441 | logging.info( 442 | "%s version tag unchanged (%s)", self._project, latest_version_tag 443 | ) 444 | else: 445 | logging.info( 446 | "%s version tag changed: %s -> %s", 447 | self._project, 448 | self._g.vim_version_tag, 449 | latest_version_tag, 450 | ) 451 | self._g.vim_version_tag = latest_version_tag 452 | elif r.status_code == HTTPStatus.NOT_MODIFIED and self._g.refs_etag: 453 | logging.info("Initial %s GraphQL request: HTTP Not Modified", self._project) 454 | else: 455 | raise RuntimeError( 456 | f"Initial {self._project} GraphQL request: " 457 | f"bad HTTP status {r.status_code}" 458 | ) 459 | 460 | def _list_docs_dir(self, git_ref): 461 | """ 462 | Generator that yields '(name: str, is_modified: bool)' pairs on iteration, 463 | representing the set of filenames in the 'runtime/doc' and 464 | 'runtime/pack/dist/opt/{matchit,editorconfig}/doc' directories (if they exist) 465 | of the current project, and whether each one is new/modified or not. 466 | 'git_ref' is the Git ref to use when looking up the directory. 467 | This function both reads and writes 'self._rfi_map'. 468 | """ 469 | response = self._github_graphql_request( 470 | "GetDirs", 471 | variables={ 472 | "org": self._project, 473 | "repo": self._project, 474 | "expr1": git_ref + ":runtime/doc", 475 | "expr2": git_ref + ":runtime/pack/dist/opt/matchit/doc", 476 | "expr3": git_ref + ":runtime/pack/dist/opt/editorconfig/doc", 477 | }, 478 | etag=self._g.docdir_etag, 479 | ) 480 | if response.status_code == HTTPStatus.NOT_MODIFIED: 481 | logging.info("%s doc dir not modified", self._project) 482 | return 483 | if response.status_code != HTTPStatus.OK: 484 | raise RuntimeError(f"Bad doc dir HTTP status {response.status_code}") 485 | etag = response.header("ETag") 486 | self._g.docdir_etag = etag.encode() if etag is not None else None 487 | logging.info("%s doc dir modified, new etag is %s", self._project, etag) 488 | resp = json.loads(response.body)["data"]["repository"] 489 | done = set() # "tags" filename exists in multiple dirs, only want first one 490 | entries = [(resp[d] or {}).get("entries", []) for d in ("dir1", "dir2", "dir3")] 491 | for item in itertools.chain(*entries): 492 | name = item["name"] 493 | if item["type"] != "blob" or not DOC_ITEM_RE.match(name) or name in done: 494 | continue 495 | done.add(name) 496 | git_sha = item["oid"].encode() 497 | rfi = self._rfi_map.get(name) 498 | if rfi is None: 499 | logging.info("Found new '%s:%s'", self._project, name) 500 | self._rfi_map[name] = RawFileInfo( 501 | id=f"{self._project}:{name}", project=self._project, git_sha=git_sha 502 | ) 503 | yield name, True 504 | elif rfi.git_sha == git_sha: 505 | logging.debug("Found unchanged '%s:%s'", self._project, name) 506 | yield name, False 507 | else: 508 | logging.info("Found changed '%s:%s'", self._project, name) 509 | rfi.git_sha = git_sha 510 | yield name, True 511 | 512 | def _github_graphql_request(self, query_name, variables=None, etag=None): 513 | """ 514 | Make GitHub GraphQL API request. 515 | """ 516 | logging.info("Making %s GitHub GraphQL query: %s", self._project, query_name) 517 | headers = { 518 | "Authorization": "token " + secret.github_token(), 519 | } 520 | if etag is not None: 521 | headers["If-None-Match"] = etag.decode() 522 | body = {"query": GITHUB_GRAPHQL_QUERIES[query_name]} 523 | if variables is not None: 524 | body["variables"] = variables 525 | response = self._http_client.post( 526 | GITHUB_GRAPHQL_API_URL, json=body, headers=headers 527 | ) 528 | logging.info( 529 | "%s GitHub %s HTTP status: %s", 530 | self._project, 531 | query_name, 532 | response.status_code, 533 | ) 534 | return response 535 | 536 | def _save_tags_json(self): 537 | """ 538 | Obtain list of tag/link pairs from 'self._h2h' and save to Datastore. 539 | """ 540 | tags = self._h2h.sorted_tag_href_pairs() 541 | logging.info("Saving %d %s (tag, href) pairs", len(tags), self._project) 542 | TagsInfo(id=self._project, tags=tags).put() 543 | 544 | def _get_file_and_translate(self, name, translate_if_not_modified, sources=None): 545 | """ 546 | Get file with given 'name' and translate to HTML. 547 | 'translate_if_not_modified' controls whether to translate to HTML even if the 548 | file was not modified. 549 | 'sources' is as for '_get_file'; a sensible default based on 550 | 'translate_if_not_modified' is chosen if not provided. 551 | """ 552 | if sources is None: 553 | sources = "http,db" if translate_if_not_modified else "http" 554 | result = self._get_file(name, sources) 555 | if translate_if_not_modified or result.is_modified: 556 | self._translate(name, result.content) 557 | 558 | def _get_file_and_add_tags(self, name, sources): 559 | """ 560 | Get file with given 'name' and add tags from it to 'self._h2h'. 561 | 'sources' is as for '_get_file'. 562 | """ 563 | result = self._get_file(name, sources) 564 | self._h2h.add_tags(name, result.content.decode()) 565 | 566 | def _get_file(self, name, sources): 567 | """ 568 | Get file with given 'name' via HTTP and/or from the Datastore, based on 569 | 'sources', which should be one of "http", "db", "http,db". If a new/modified 570 | file was retrieved via HTTP, save raw file (info) to Datastore as needed. 571 | """ 572 | rfi = self._rfi_map.get(name) 573 | result = None 574 | sources_set = set(sources.split(",")) 575 | 576 | if "http" in sources_set: 577 | url = self._download_url(name) 578 | headers = {} 579 | if rfi is None: 580 | rfi = self._rfi_map[name] = RawFileInfo( 581 | id=f"{self._project}:{name}", project=self._project 582 | ) 583 | if rfi.etag is not None: 584 | headers["If-None-Match"] = rfi.etag.decode() 585 | logging.info("Fetching %s", url) 586 | response = self._http_client.get(url, headers) 587 | logging.info("Fetched %s -> HTTP %s", url, response.status_code) 588 | result = GetFileResult(response) # raises exception on bad HTTP status 589 | if (etag := response.header("ETag")) is not None: 590 | rfi.etag = etag.encode() 591 | if result.is_modified: 592 | save_raw_file(rfi, result.content) 593 | return result 594 | 595 | if "db" in sources_set: 596 | logging.info("Fetching '%s:%s' from datastore", self._project, name) 597 | rfc = RawFileContent.get_by_id(f"{self._project}:{name}") 598 | logging.info("Fetched '%s:%s' from datastore", self._project, name) 599 | return GetFileResult(rfc) 600 | 601 | return result 602 | 603 | def _download_url(self, name): 604 | if name == FAQ_NAME: 605 | return FAQ_BASE_URL + FAQ_NAME 606 | ref = self._g.master_sha if self._project == "vim" else self._g.vim_version_tag 607 | base = f"{GITHUB_DOWNLOAD_URL_BASE}{self._project}/{self._project}/{ref}" 608 | if name == MATCHIT_NAME: 609 | return f"{base}/runtime/pack/dist/opt/matchit/doc/{name}" 610 | elif name == EDITORCONFIG_NAME and self._project == "vim": 611 | # neovim has this file in its main doc dir 612 | return f"{base}/runtime/pack/dist/opt/editorconfig/doc/{name}" 613 | else: 614 | return f"{base}/runtime/doc/{name}" 615 | 616 | def _translate(self, name, content): 617 | """ 618 | Translate given file to HTML and save to Datastore. 619 | """ 620 | logging.info("Translating '%s:%s' to HTML", self._project, name) 621 | phead, pparts = to_html(self._project, name, content, self._h2h) 622 | logging.info( 623 | "Saving HTML translation of '%s:%s' to Datastore", self._project, name 624 | ) 625 | save_transactional([phead, *pparts]) 626 | 627 | def _get_all_rfi(self, no_rfi): 628 | if no_rfi: 629 | return {} 630 | else: 631 | rfi_list = RawFileInfo.query(RawFileInfo.project == self._project).fetch() 632 | return {r.key.id().split(":")[1]: r for r in rfi_list} 633 | 634 | def _spawn(self, f, *args, **kwargs): 635 | def g(): 636 | with self._app.app_context(), ndb_context(): 637 | return f(*args, **kwargs) 638 | 639 | return self._greenlet_pool.spawn(g) 640 | 641 | def _join_greenlets(self, greenlets): 642 | for greenlet in gevent.iwait(greenlets): 643 | try: 644 | greenlet.get() 645 | except Exception as e: 646 | logging.error(e) 647 | self._had_exception = True 648 | 649 | 650 | class GetFileResult: 651 | def __init__(self, obj): 652 | if isinstance(obj, HttpResponse): 653 | self.content = obj.body 654 | if obj.status_code == HTTPStatus.OK: 655 | self.is_modified = True 656 | elif obj.status_code == HTTPStatus.NOT_MODIFIED: 657 | self.is_modified = False 658 | else: 659 | raise RuntimeError( 660 | f"Fetching {obj.url} yielded bad HTTP status {obj.status_code}" 661 | ) 662 | elif isinstance(obj, RawFileContent): 663 | self.content = obj.data 664 | self.is_modified = False 665 | 666 | 667 | def to_html(project, name, content, h2h): 668 | content_str = content.decode() 669 | html = h2h.to_html(name, content_str).encode() 670 | etag = base64.b64encode(sha1(html)) 671 | datalen = len(html) 672 | phead = ProcessedFileHead( 673 | id=f"{project}:{name}", 674 | project=project, 675 | encoding=b"UTF-8", 676 | etag=etag, 677 | used_assets=assets.curr_asset_ids(), 678 | ) 679 | pparts = [] 680 | if datalen > MAX_DB_PART_LEN: 681 | phead.numparts = 0 682 | for i in range(0, datalen, MAX_DB_PART_LEN): 683 | part = html[i : (i + MAX_DB_PART_LEN)] 684 | if i == 0: 685 | phead.data0 = part 686 | else: 687 | partname = f"{project}:{name}:{phead.numparts}" 688 | pparts.append(ProcessedFilePart(id=partname, data=part, etag=etag)) 689 | phead.numparts += 1 690 | else: 691 | phead.numparts = 1 692 | phead.data0 = html 693 | return phead, pparts 694 | 695 | 696 | def save_raw_file(rfi, content): 697 | rfi_id = rfi.key.id() 698 | project, name = rfi_id.split(":") 699 | if project == "neovim" or name in (HELP_NAME, FAQ_NAME, *EXTRA_NAMES): 700 | logging.info("Saving raw file '%s' (info and content) to Datastore", rfi_id) 701 | rfc = RawFileContent( 702 | id=rfi_id, project=project, data=content, encoding=b"UTF-8" 703 | ) 704 | save_transactional([rfi, rfc]) 705 | else: 706 | logging.info("Saving raw file '%s' (info only) to Datastore", rfi_id) 707 | rfi.put() 708 | 709 | 710 | def wipe_db(model, project): 711 | keys = model.query(model.project == project).fetch(keys_only=True) 712 | google.cloud.ndb.delete_multi(keys) 713 | 714 | 715 | @google.cloud.ndb.transactional(xg=True) 716 | def save_transactional(entities): 717 | google.cloud.ndb.put_multi(entities) 718 | 719 | 720 | def version_from_tag(version_tag): 721 | if m := VERSION_TAG_RE.match(version_tag): 722 | return m.group(1) 723 | else: 724 | return version_tag 725 | 726 | 727 | def sha1(content): 728 | digest = hashlib.sha1() # noqa: S324 729 | digest.update(content) 730 | return digest.digest() 731 | 732 | 733 | def utcnow(): 734 | # datetime.datetime.utcnow() is deprecated; the following does the same thing 735 | return datetime.datetime.now(datetime.UTC).replace(tzinfo=None) 736 | 737 | 738 | def handle_enqueue_update(): 739 | req = flask.request 740 | 741 | is_cron = req.headers.get("X-Appengine-Cron") == "true" 742 | 743 | # https://cloud.google.com/appengine/docs/standard/scheduling-jobs-with-cron-yaml#securing_urls_for_cron 744 | if ( 745 | not is_cron 746 | and os.environ.get("VIMHELP_ENV") != "dev" 747 | and secret.admin_password().encode() not in req.query_string 748 | ): 749 | raise werkzeug.exceptions.Forbidden() 750 | 751 | logging.info("Enqueueing update") 752 | 753 | client = google.cloud.tasks.CloudTasksClient() 754 | queue_name = client.queue_path( 755 | os.environ["GOOGLE_CLOUD_PROJECT"], "us-central1", "update2" 756 | ) 757 | body = req.query_string 758 | if b"project=" not in body: 759 | body += b"&project=" + flask.g.project.encode() 760 | task = { 761 | "app_engine_http_request": { 762 | "http_method": "POST", 763 | "relative_uri": "/update", 764 | "body": body, 765 | } 766 | } 767 | response = client.create_task(parent=queue_name, task=task) 768 | logging.info("Task %s enqueued, ETA %s", response.name, response.schedule_time) 769 | 770 | if is_cron: 771 | return flask.Response() 772 | else: 773 | return "Successfully enqueued update task." 774 | -------------------------------------------------------------------------------- /vimhelp/static/tom-select.base.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tom Select v2.4.3 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | */ 5 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).TomSelect=e()}(this,(function(){"use strict" 6 | function t(t,e){t.split(/\s+/).forEach((t=>{e(t)}))}class e{constructor(){this._events={}}on(e,i){t(e,(t=>{const e=this._events[t]||[] 7 | e.push(i),this._events[t]=e}))}off(e,i){var s=arguments.length 8 | 0!==s?t(e,(t=>{if(1===s)return void delete this._events[t] 9 | const e=this._events[t] 10 | void 0!==e&&(e.splice(e.indexOf(i),1),this._events[t]=e)})):this._events={}}trigger(e,...i){var s=this 11 | t(e,(t=>{const e=s._events[t] 12 | void 0!==e&&e.forEach((t=>{t.apply(s,i)}))}))}}const i=t=>(t=t.filter(Boolean)).length<2?t[0]||"":1==l(t)?"["+t.join("")+"]":"(?:"+t.join("|")+")",s=t=>{if(!o(t))return t.join("") 13 | let e="",i=0 14 | const s=()=>{i>1&&(e+="{"+i+"}")} 15 | return t.forEach(((n,o)=>{n!==t[o-1]?(s(),e+=n,i=1):i++})),s(),e},n=t=>{let e=Array.from(t) 16 | return i(e)},o=t=>new Set(t).size!==t.length,r=t=>(t+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),l=t=>t.reduce(((t,e)=>Math.max(t,a(e))),0),a=t=>Array.from(t).length,c=t=>{if(1===t.length)return[[t]] 17 | let e=[] 18 | const i=t.substring(1) 19 | return c(i).forEach((function(i){let s=i.slice(0) 20 | s[0]=t.charAt(0)+s[0],e.push(s),s=i.slice(0),s.unshift(t.charAt(0)),e.push(s)})),e},d=[[0,65535]] 21 | let u,p 22 | const h={},g={"/":"⁄∕",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"} 23 | for(let t in g){let e=g[t]||"" 24 | for(let i=0;it.normalize(e),v=t=>Array.from(t).reduce(((t,e)=>t+y(e)),""),y=t=>(t=m(t).toLowerCase().replace(f,(t=>h[t]||"")),m(t,"NFC")) 26 | const O=t=>{const e={},i=(t,i)=>{const s=e[t]||new Set,o=new RegExp("^"+n(s)+"$","iu") 27 | i.match(o)||(s.add(r(i)),e[t]=s)} 28 | for(let e of function*(t){for(const[e,i]of t)for(let t=e;t<=i;t++){let e=String.fromCharCode(t),i=v(e) 29 | i!=e.toLowerCase()&&(i.length>3||0!=i.length&&(yield{folded:i,composed:e,code_point:t}))}}(t))i(e.folded,e.folded),i(e.folded,e.composed) 30 | return e},b=t=>{const e=O(t),s={} 31 | let o=[] 32 | for(let t in e){let i=e[t] 33 | i&&(s[t]=n(i)),t.length>1&&o.push(r(t))}o.sort(((t,e)=>e.length-t.length)) 34 | const l=i(o) 35 | return p=new RegExp("^"+l,"u"),s},w=(t,e=1)=>(e=Math.max(e,t.length-1),i(c(t).map((t=>((t,e=1)=>{let i=0 36 | return t=t.map((t=>(u[t]&&(i+=t.length),u[t]||t))),i>=e?s(t):""})(t,e))))),I=(t,e=!0)=>{let n=t.length>1?1:0 37 | return i(t.map((t=>{let i=[] 38 | const o=e?t.length():t.length()-1 39 | for(let e=0;e{for(const i of e){if(i.start!=t.start||i.end!=t.end)continue 41 | if(i.substrs.join("")!==t.substrs.join(""))continue 42 | let e=t.parts 43 | const s=t=>{for(const i of e){if(i.start===t.start&&i.substr===t.substr)return!1 44 | if(1!=t.length&&1!=i.length){if(t.starti.start)return!0 45 | if(i.startt.start)return!0}}return!1} 46 | if(!(i.parts.filter(s).length>0))return!0}return!1} 47 | class A{parts 48 | substrs 49 | start 50 | end 51 | constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(t){t&&(this.parts.push(t),this.substrs.push(t.substr),this.start=Math.min(t.start,this.start),this.end=Math.max(t.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(t,e){let i=new A,s=JSON.parse(JSON.stringify(this.parts)),n=s.pop() 52 | for(const t of s)i.add(t) 53 | let o=e.substr.substring(0,t-n.start),r=o.length 54 | return i.add({start:n.start,end:n.start+r,length:r,substr:o}),i}}const _=t=>{void 0===u&&(u=b(d)),t=v(t) 55 | let e="",i=[new A] 56 | for(let s=0;s0){l=l.sort(((t,e)=>t.length()-e.length())) 65 | for(let t of l)S(t,i)||i.push(t)}else if(s>0&&1==a.size&&!a.has("3")){e+=I(i,!1) 66 | let t=new A 67 | const s=i[0] 68 | s&&t.add(s.last()),i=[t]}}return e+=I(i,!0),e},C=(t,e)=>{if(t)return t[e]},F=(t,e)=>{if(t){for(var i,s=e.split(".");(i=s.shift())&&(t=t[i]););return t}},x=(t,e,i)=>{var s,n 69 | return t?(t+="",null==e.regex||-1===(n=t.search(e.regex))?0:(s=e.string.length/t.length,0===n&&(s+=.5),s*i)):0},L=(t,e)=>{var i=t[e] 70 | if("function"==typeof i)return i 71 | i&&!Array.isArray(i)&&(t[e]=[i])},k=(t,e)=>{if(Array.isArray(t))t.forEach(e) 72 | else for(var i in t)t.hasOwnProperty(i)&&e(t[i],i)},E=(t,e)=>"number"==typeof t&&"number"==typeof e?t>e?1:t(e=v(e+"").toLowerCase())?1:e>t?-1:0 73 | class T{items 74 | settings 75 | constructor(t,e){this.items=t,this.settings=e||{diacritics:!0}}tokenize(t,e,i){if(!t||!t.length)return[] 76 | const s=[],n=t.split(/\s+/) 77 | var o 78 | return i&&(o=new RegExp("^("+Object.keys(i).map(r).join("|")+"):(.*)$")),n.forEach((t=>{let i,n=null,l=null 79 | o&&(i=t.match(o))&&(n=i[1],t=i[2]),t.length>0&&(l=this.settings.diacritics?_(t)||null:r(t),l&&e&&(l="\\b"+l)),s.push({string:t,regex:l?new RegExp(l,"iu"):null,field:n})})),s}getScoreFunction(t,e){var i=this.prepareSearch(t,e) 80 | return this._getScoreFunction(i)}_getScoreFunction(t){const e=t.tokens,i=e.length 81 | if(!i)return function(){return 0} 82 | const s=t.options.fields,n=t.weights,o=s.length,r=t.getAttrFn 83 | if(!o)return function(){return 1} 84 | const l=1===o?function(t,e){const i=s[0].field 85 | return x(r(e,i),t,n[i]||1)}:function(t,e){var i=0 86 | if(t.field){const s=r(e,t.field) 87 | !t.regex&&s?i+=1/o:i+=x(s,t,1)}else k(n,((s,n)=>{i+=x(r(e,n),t,s)})) 88 | return i/o} 89 | return 1===i?function(t){return l(e[0],t)}:"and"===t.options.conjunction?function(t){var s,n=0 90 | for(let i of e){if((s=l(i,t))<=0)return 0 91 | n+=s}return n/i}:function(t){var s=0 92 | return k(e,(e=>{s+=l(e,t)})),s/i}}getSortFunction(t,e){var i=this.prepareSearch(t,e) 93 | return this._getSortFunction(i)}_getSortFunction(t){var e,i=[] 94 | const s=this,n=t.options,o=!t.query&&n.sort_empty?n.sort_empty:n.sort 95 | if("function"==typeof o)return o.bind(this) 96 | const r=function(e,i){return"$score"===e?i.score:t.getAttrFn(s.items[i.id],e)} 97 | if(o)for(let e of o)(t.query||"$score"!==e.field)&&i.push(e) 98 | if(t.query){e=!0 99 | for(let t of i)if("$score"===t.field){e=!1 100 | break}e&&i.unshift({field:"$score",direction:"desc"})}else i=i.filter((t=>"$score"!==t.field)) 101 | return i.length?function(t,e){var s,n 102 | for(let o of i){if(n=o.field,s=("desc"===o.direction?-1:1)*E(r(n,t),r(n,e)))return s}return 0}:null}prepareSearch(t,e){const i={} 103 | var s=Object.assign({},e) 104 | if(L(s,"sort"),L(s,"sort_empty"),s.fields){L(s,"fields") 105 | const t=[] 106 | s.fields.forEach((e=>{"string"==typeof e&&(e={field:e,weight:1}),t.push(e),i[e.field]="weight"in e?e.weight:1})),s.fields=t}return{options:s,query:t.toLowerCase().trim(),tokens:this.tokenize(t,s.respect_word_boundaries,i),total:0,items:[],weights:i,getAttrFn:s.nesting?F:C}}search(t,e){var i,s,n=this 107 | s=this.prepareSearch(t,e),e=s.options,t=s.query 108 | const o=e.score||n._getScoreFunction(s) 109 | t.length?k(n.items,((t,n)=>{i=o(t),(!1===e.filter||i>0)&&s.items.push({score:i,id:n})})):k(n.items,((t,e)=>{s.items.push({score:1,id:e})})) 110 | const r=n._getSortFunction(s) 111 | return r&&s.items.sort(r),s.total=s.items.length,"number"==typeof e.limit&&(s.items=s.items.slice(0,e.limit)),s}}const P=t=>null==t?null:$(t),$=t=>"boolean"==typeof t?t?"1":"0":t+"",V=t=>(t+"").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""),j=(t,e)=>{var i 112 | return function(s,n){var o=this 113 | i&&(o.loading=Math.max(o.loading-1,0),clearTimeout(i)),i=setTimeout((function(){i=null,o.loadedSearches[s]=!0,t.call(o,s,n)}),e)}},q=(t,e,i)=>{var s,n=t.trigger,o={} 114 | for(s of(t.trigger=function(){var i=arguments[0] 115 | if(-1===e.indexOf(i))return n.apply(t,arguments) 116 | o[i]=arguments},i.apply(t,[]),t.trigger=n,e))s in o&&n.apply(t,o[s])},D=(t,e=!1)=>{t&&(t.preventDefault(),e&&t.stopPropagation())},R=(t,e,i,s)=>{t.addEventListener(e,i,s)},H=(t,e)=>!!e&&(!!e[t]&&1===(e.altKey?1:0)+(e.ctrlKey?1:0)+(e.shiftKey?1:0)+(e.metaKey?1:0)),N=(t,e)=>{const i=t.getAttribute("id") 117 | return i||(t.setAttribute("id",e),e)},M=t=>t.replace(/[\\"']/g,"\\$&"),z=(t,e)=>{e&&t.append(e)},B=(t,e)=>{if(Array.isArray(t))t.forEach(e) 118 | else for(var i in t)t.hasOwnProperty(i)&&e(t[i],i)},K=t=>{if(t.jquery)return t[0] 119 | if(t instanceof HTMLElement)return t 120 | if(Q(t)){var e=document.createElement("template") 121 | return e.innerHTML=t.trim(),e.content.firstChild}return document.querySelector(t)},Q=t=>"string"==typeof t&&t.indexOf("<")>-1,G=(t,e)=>{var i=document.createEvent("HTMLEvents") 122 | i.initEvent(e,!0,!1),t.dispatchEvent(i)},J=(t,e)=>{Object.assign(t.style,e)},U=(t,...e)=>{var i=X(e);(t=Y(t)).map((t=>{i.map((e=>{t.classList.add(e)}))}))},W=(t,...e)=>{var i=X(e);(t=Y(t)).map((t=>{i.map((e=>{t.classList.remove(e)}))}))},X=t=>{var e=[] 123 | return B(t,(t=>{"string"==typeof t&&(t=t.trim().split(/[\t\n\f\r\s]/)),Array.isArray(t)&&(e=e.concat(t))})),e.filter(Boolean)},Y=t=>(Array.isArray(t)||(t=[t]),t),Z=(t,e,i)=>{if(!i||i.contains(t))for(;t&&t.matches;){if(t.matches(e))return t 124 | t=t.parentNode}},tt=(t,e=0)=>e>0?t[t.length-1]:t[0],et=(t,e)=>{if(!t)return-1 125 | e=e||t.nodeName 126 | for(var i=0;t=t.previousElementSibling;)t.matches(e)&&i++ 127 | return i},it=(t,e)=>{B(e,((e,i)=>{null==e?t.removeAttribute(i):t.setAttribute(i,""+e)}))},st=(t,e)=>{t.parentNode&&t.parentNode.replaceChild(e,t)},nt=(t,e)=>{if(null===e)return 128 | if("string"==typeof e){if(!e.length)return 129 | e=new RegExp(e,"i")}const i=t=>3===t.nodeType?(t=>{var i=t.data.match(e) 130 | if(i&&t.data.length>0){var s=document.createElement("span") 131 | s.className="highlight" 132 | var n=t.splitText(i.index) 133 | n.splitText(i[0].length) 134 | var o=n.cloneNode(!0) 135 | return s.appendChild(o),st(n,s),1}return 0})(t):((t=>{1!==t.nodeType||!t.childNodes||/(script|style)/i.test(t.tagName)||"highlight"===t.className&&"SPAN"===t.tagName||Array.from(t.childNodes).forEach((t=>{i(t)}))})(t),0) 136 | i(t)},ot="undefined"!=typeof navigator&&/Mac/.test(navigator.userAgent)?"metaKey":"ctrlKey" 137 | var rt={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(t){return t.length>0},render:{}} 138 | function lt(t,e){var i=Object.assign({},rt,e),s=i.dataAttr,n=i.labelField,o=i.valueField,r=i.disabledField,l=i.optgroupField,a=i.optgroupLabelField,c=i.optgroupValueField,d=t.tagName.toLowerCase(),u=t.getAttribute("placeholder")||t.getAttribute("data-placeholder") 139 | if(!u&&!i.allowEmptyOption){let e=t.querySelector('option[value=""]') 140 | e&&(u=e.textContent)}var p={placeholder:u,options:[],optgroups:[],items:[],maxItems:null} 141 | return"select"===d?(()=>{var e,d=p.options,u={},h=1 142 | let g=0 143 | var f=t=>{var e=Object.assign({},t.dataset),i=s&&e[s] 144 | return"string"==typeof i&&i.length&&(e=Object.assign(e,JSON.parse(i))),e},m=(t,e)=>{var s=P(t.value) 145 | if(null!=s&&(s||i.allowEmptyOption)){if(u.hasOwnProperty(s)){if(e){var a=u[s][l] 146 | a?Array.isArray(a)?a.push(e):u[s][l]=[a,e]:u[s][l]=e}}else{var c=f(t) 147 | c[n]=c[n]||t.textContent,c[o]=c[o]||s,c[r]=c[r]||t.disabled,c[l]=c[l]||e,c.$option=t,c.$order=c.$order||++g,u[s]=c,d.push(c)}t.selected&&p.items.push(s)}} 148 | p.maxItems=t.hasAttribute("multiple")?null:1,B(t.children,(t=>{var i,s,n 149 | "optgroup"===(e=t.tagName.toLowerCase())?((n=f(i=t))[a]=n[a]||i.getAttribute("label")||"",n[c]=n[c]||h++,n[r]=n[r]||i.disabled,n.$order=n.$order||++g,p.optgroups.push(n),s=n[c],B(i.children,(t=>{m(t,s)}))):"option"===e&&m(t)}))})():(()=>{const e=t.getAttribute(s) 150 | if(e)p.options=JSON.parse(e),B(p.options,(t=>{p.items.push(t[o])})) 151 | else{var r=t.value.trim()||"" 152 | if(!i.allowEmptyOption&&!r.length)return 153 | const e=r.split(i.delimiter) 154 | B(e,(t=>{const e={} 155 | e[n]=t,e[o]=t,p.options.push(e)})),p.items=e}})(),Object.assign({},rt,p,e)}var at=0 156 | class ct extends(function(t){return t.plugins={},class extends t{constructor(...t){super(...t),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(e,i){t.plugins[e]={name:e,fn:i}}initializePlugins(t){var e,i 157 | const s=this,n=[] 158 | if(Array.isArray(t))t.forEach((t=>{"string"==typeof t?n.push(t):(s.plugins.settings[t.name]=t.options,n.push(t.name))})) 159 | else if(t)for(e in t)t.hasOwnProperty(e)&&(s.plugins.settings[e]=t[e],n.push(e)) 160 | for(;i=n.shift();)s.require(i)}loadPlugin(e){var i=this,s=i.plugins,n=t.plugins[e] 161 | if(!t.plugins.hasOwnProperty(e))throw new Error('Unable to find "'+e+'" plugin') 162 | s.requested[e]=!0,s.loaded[e]=n.fn.apply(i,[i.plugins.settings[e]||{}]),s.names.push(e)}require(t){var e=this,i=e.plugins 163 | if(!e.plugins.loaded.hasOwnProperty(t)){if(i.requested[t])throw new Error('Plugin has circular dependency ("'+t+'")') 164 | e.loadPlugin(t)}return i.loaded[t]}}}(e)){constructor(t,e){var i 165 | super(),this.order=0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,at++ 166 | var s=K(t) 167 | if(s.tomselect)throw new Error("Tom Select already initialized on this element") 168 | s.tomselect=this,i=(window.getComputedStyle&&window.getComputedStyle(s,null)).getPropertyValue("direction") 169 | const n=lt(s,e) 170 | this.settings=n,this.input=s,this.tabIndex=s.tabIndex||0,this.is_select_tag="select"===s.tagName.toLowerCase(),this.rtl=/rtl/i.test(i),this.inputId=N(s,"tomselect-"+at),this.isRequired=s.required,this.sifter=new T(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(1===n.maxItems?"single":"multi"),"boolean"!=typeof n.hideSelected&&(n.hideSelected="multi"===n.mode),"boolean"!=typeof n.hidePlaceholder&&(n.hidePlaceholder="multi"!==n.mode) 171 | var o=n.createFilter 172 | "function"!=typeof o&&("string"==typeof o&&(o=new RegExp(o)),o instanceof RegExp?n.createFilter=t=>o.test(t):n.createFilter=t=>this.settings.duplicates||!this.options[t]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates() 173 | const r=K("
"),l=K("
"),a=this._render("dropdown"),c=K('
'),d=this.input.getAttribute("class")||"",u=n.mode 174 | var p 175 | if(U(r,n.wrapperClass,d,u),U(l,n.controlClass),z(r,l),U(a,n.dropdownClass,u),n.copyClassesToDropdown&&U(a,d),U(c,n.dropdownContentClass),z(a,c),K(n.dropdownParent||r).appendChild(a),Q(n.controlInput)){p=K(n.controlInput) 176 | B(["autocorrect","autocapitalize","autocomplete","spellcheck"],(t=>{s.getAttribute(t)&&it(p,{[t]:s.getAttribute(t)})})),p.tabIndex=-1,l.appendChild(p),this.focus_node=p}else n.controlInput?(p=K(n.controlInput),this.focus_node=p):(p=K(""),this.focus_node=l) 177 | this.wrapper=r,this.dropdown=a,this.dropdown_content=c,this.control=l,this.control_input=p,this.setup()}setup(){const t=this,e=t.settings,i=t.control_input,s=t.dropdown,n=t.dropdown_content,o=t.wrapper,l=t.control,a=t.input,c=t.focus_node,d={passive:!0},u=t.inputId+"-ts-dropdown" 178 | it(n,{id:u}),it(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":u}) 179 | const p=N(c,t.inputId+"-ts-control"),h="label[for='"+(t=>t.replace(/['"\\]/g,"\\$&"))(t.inputId)+"']",g=document.querySelector(h),f=t.focus.bind(t) 180 | if(g){R(g,"click",f),it(g,{for:p}) 181 | const e=N(g,t.inputId+"-ts-label") 182 | it(c,{"aria-labelledby":e}),it(n,{"aria-labelledby":e})}if(o.style.width=a.style.width,t.plugins.names.length){const e="plugin-"+t.plugins.names.join(" plugin-") 183 | U([o,s],e)}(null===e.maxItems||e.maxItems>1)&&t.is_select_tag&&it(a,{multiple:"multiple"}),e.placeholder&&it(i,{placeholder:e.placeholder}),!e.splitOn&&e.delimiter&&(e.splitOn=new RegExp("\\s*"+r(e.delimiter)+"+\\s*")),e.load&&e.loadThrottle&&(e.load=j(e.load,e.loadThrottle)),R(s,"mousemove",(()=>{t.ignoreHover=!1})),R(s,"mouseenter",(e=>{var i=Z(e.target,"[data-selectable]",s) 184 | i&&t.onOptionHover(e,i)}),{capture:!0}),R(s,"click",(e=>{const i=Z(e.target,"[data-selectable]") 185 | i&&(t.onOptionSelect(e,i),D(e,!0))})),R(l,"click",(e=>{var s=Z(e.target,"[data-ts-item]",l) 186 | s&&t.onItemSelect(e,s)?D(e,!0):""==i.value&&(t.onClick(),D(e,!0))})),R(c,"keydown",(e=>t.onKeyDown(e))),R(i,"keypress",(e=>t.onKeyPress(e))),R(i,"input",(e=>t.onInput(e))),R(c,"blur",(e=>t.onBlur(e))),R(c,"focus",(e=>t.onFocus(e))),R(i,"paste",(e=>t.onPaste(e))) 187 | const m=e=>{const n=e.composedPath()[0] 188 | if(!o.contains(n)&&!s.contains(n))return t.isFocused&&t.blur(),void t.inputState() 189 | n==i&&t.isOpen?e.stopPropagation():D(e,!0)},v=()=>{t.isOpen&&t.positionDropdown()} 190 | R(document,"mousedown",m),R(window,"scroll",v,d),R(window,"resize",v,d),this._destroy=()=>{document.removeEventListener("mousedown",m),window.removeEventListener("scroll",v),window.removeEventListener("resize",v),g&&g.removeEventListener("click",f)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",t.wrapper),t.sync(!1),e.items=[],delete e.optgroups,delete e.options,R(a,"invalid",(()=>{t.isValid&&(t.isValid=!1,t.isInvalid=!0,t.refreshState())})),t.updateOriginalInput(),t.refreshItems(),t.close(!1),t.inputState(),t.isSetup=!0,a.disabled?t.disable():a.readOnly?t.setReadOnly(!0):t.enable(),t.on("change",this.onChange),U(a,"tomselected","ts-hidden-accessible"),t.trigger("initialize"),!0===e.preload&&t.preload()}setupOptions(t=[],e=[]){this.addOptions(t),B(e,(t=>{this.registerOptionGroup(t)}))}setupTemplates(){var t=this,e=t.settings.labelField,i=t.settings.optgroupLabelField,s={optgroup:t=>{let e=document.createElement("div") 191 | return e.className="optgroup",e.appendChild(t.options),e},optgroup_header:(t,e)=>'
'+e(t[i])+"
",option:(t,i)=>"
"+i(t[e])+"
",item:(t,i)=>"
"+i(t[e])+"
",option_create:(t,e)=>'
Add '+e(t.input)+"
",no_results:()=>'
No results found
',loading:()=>'
',not_loading:()=>{},dropdown:()=>"
"} 192 | t.settings.render=Object.assign({},s,t.settings.render)}setupCallbacks(){var t,e,i={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"} 193 | for(t in i)(e=this.settings[i[t]])&&this.on(t,e)}sync(t=!0){const e=this,i=t?lt(e.input,{delimiter:e.settings.delimiter}):e.settings 194 | e.setupOptions(i.options,i.optgroups),e.setValue(i.items||[],!0),e.lastQuery=null}onClick(){var t=this 195 | if(t.activeItems.length>0)return t.clearActiveItems(),void t.focus() 196 | t.isFocused&&t.isOpen?t.blur():t.focus()}onMouseDown(){}onChange(){G(this.input,"input"),G(this.input,"change")}onPaste(t){var e=this 197 | e.isInputHidden||e.isLocked?D(t):e.settings.splitOn&&setTimeout((()=>{var t=e.inputValue() 198 | if(t.match(e.settings.splitOn)){var i=t.trim().split(e.settings.splitOn) 199 | B(i,(t=>{P(t)&&(this.options[t]?e.addItem(t):e.createItem(t))}))}}),0)}onKeyPress(t){var e=this 200 | if(!e.isLocked){var i=String.fromCharCode(t.keyCode||t.which) 201 | return e.settings.create&&"multi"===e.settings.mode&&i===e.settings.delimiter?(e.createItem(),void D(t)):void 0}D(t)}onKeyDown(t){var e=this 202 | if(e.ignoreHover=!0,e.isLocked)9!==t.keyCode&&D(t) 203 | else{switch(t.keyCode){case 65:if(H(ot,t)&&""==e.control_input.value)return D(t),void e.selectAll() 204 | break 205 | case 27:return e.isOpen&&(D(t,!0),e.close()),void e.clearActiveItems() 206 | case 40:if(!e.isOpen&&e.hasOptions)e.open() 207 | else if(e.activeOption){let t=e.getAdjacent(e.activeOption,1) 208 | t&&e.setActiveOption(t)}return void D(t) 209 | case 38:if(e.activeOption){let t=e.getAdjacent(e.activeOption,-1) 210 | t&&e.setActiveOption(t)}return void D(t) 211 | case 13:return void(e.canSelect(e.activeOption)?(e.onOptionSelect(t,e.activeOption),D(t)):(e.settings.create&&e.createItem()||document.activeElement==e.control_input&&e.isOpen)&&D(t)) 212 | case 37:return void e.advanceSelection(-1,t) 213 | case 39:return void e.advanceSelection(1,t) 214 | case 9:return void(e.settings.selectOnTab&&(e.canSelect(e.activeOption)&&(e.onOptionSelect(t,e.activeOption),D(t)),e.settings.create&&e.createItem()&&D(t))) 215 | case 8:case 46:return void e.deleteSelection(t)}e.isInputHidden&&!H(ot,t)&&D(t)}}onInput(t){if(this.isLocked)return 216 | const e=this.inputValue() 217 | this.lastValue!==e&&(this.lastValue=e,""!=e?(this.refreshTimeout&&window.clearTimeout(this.refreshTimeout),this.refreshTimeout=((t,e)=>e>0?window.setTimeout(t,e):(t.call(null),null))((()=>{this.refreshTimeout=null,this._onInput()}),this.settings.refreshThrottle)):this._onInput())}_onInput(){const t=this.lastValue 218 | this.settings.shouldLoad.call(this,t)&&this.load(t),this.refreshOptions(),this.trigger("type",t)}onOptionHover(t,e){this.ignoreHover||this.setActiveOption(e,!1)}onFocus(t){var e=this,i=e.isFocused 219 | if(e.isDisabled||e.isReadOnly)return e.blur(),void D(t) 220 | e.ignoreFocus||(e.isFocused=!0,"focus"===e.settings.preload&&e.preload(),i||e.trigger("focus"),e.activeItems.length||(e.inputState(),e.refreshOptions(!!e.settings.openOnFocus)),e.refreshState())}onBlur(t){if(!1!==document.hasFocus()){var e=this 221 | if(e.isFocused){e.isFocused=!1,e.ignoreFocus=!1 222 | var i=()=>{e.close(),e.setActiveItem(),e.setCaret(e.items.length),e.trigger("blur")} 223 | e.settings.create&&e.settings.createOnBlur?e.createItem(null,i):i()}}}onOptionSelect(t,e){var i,s=this 224 | e.parentElement&&e.parentElement.matches("[data-disabled]")||(e.classList.contains("create")?s.createItem(null,(()=>{s.settings.closeAfterSelect&&s.close()})):void 0!==(i=e.dataset.value)&&(s.lastQuery=null,s.addItem(i),s.settings.closeAfterSelect&&s.close(),!s.settings.hideSelected&&t.type&&/click/.test(t.type)&&s.setActiveOption(e)))}canSelect(t){return!!(this.isOpen&&t&&this.dropdown_content.contains(t))}onItemSelect(t,e){var i=this 225 | return!i.isLocked&&"multi"===i.settings.mode&&(D(t),i.setActiveItem(e,t),!0)}canLoad(t){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(t)}load(t){const e=this 226 | if(!e.canLoad(t))return 227 | U(e.wrapper,e.settings.loadingClass),e.loading++ 228 | const i=e.loadCallback.bind(e) 229 | e.settings.load.call(e,t,i)}loadCallback(t,e){const i=this 230 | i.loading=Math.max(i.loading-1,0),i.lastQuery=null,i.clearActiveOption(),i.setupOptions(t,e),i.refreshOptions(i.isFocused&&!i.isInputHidden),i.loading||W(i.wrapper,i.settings.loadingClass),i.trigger("load",t,e)}preload(){var t=this.wrapper.classList 231 | t.contains("preloaded")||(t.add("preloaded"),this.load(""))}setTextboxValue(t=""){var e=this.control_input 232 | e.value!==t&&(e.value=t,G(e,"update"),this.lastValue=t)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(t,e){q(this,e?[]:["change"],(()=>{this.clear(e),this.addItems(t,e)}))}setMaxItems(t){0===t&&(t=null),this.settings.maxItems=t,this.refreshState()}setActiveItem(t,e){var i,s,n,o,r,l,a=this 233 | if("single"!==a.settings.mode){if(!t)return a.clearActiveItems(),void(a.isFocused&&a.inputState()) 234 | if("click"===(i=e&&e.type.toLowerCase())&&H("shiftKey",e)&&a.activeItems.length){for(l=a.getLastActive(),(n=Array.prototype.indexOf.call(a.control.children,l))>(o=Array.prototype.indexOf.call(a.control.children,t))&&(r=n,n=o,o=r),s=n;s<=o;s++)t=a.control.children[s],-1===a.activeItems.indexOf(t)&&a.setActiveItemClass(t) 235 | D(e)}else"click"===i&&H(ot,e)||"keydown"===i&&H("shiftKey",e)?t.classList.contains("active")?a.removeActiveItem(t):a.setActiveItemClass(t):(a.clearActiveItems(),a.setActiveItemClass(t)) 236 | a.inputState(),a.isFocused||a.focus()}}setActiveItemClass(t){const e=this,i=e.control.querySelector(".last-active") 237 | i&&W(i,"last-active"),U(t,"active last-active"),e.trigger("item_select",t),-1==e.activeItems.indexOf(t)&&e.activeItems.push(t)}removeActiveItem(t){var e=this.activeItems.indexOf(t) 238 | this.activeItems.splice(e,1),W(t,"active")}clearActiveItems(){W(this.activeItems,"active"),this.activeItems=[]}setActiveOption(t,e=!0){t!==this.activeOption&&(this.clearActiveOption(),t&&(this.activeOption=t,it(this.focus_node,{"aria-activedescendant":t.getAttribute("id")}),it(t,{"aria-selected":"true"}),U(t,"active"),e&&this.scrollToOption(t)))}scrollToOption(t,e){if(!t)return 239 | const i=this.dropdown_content,s=i.clientHeight,n=i.scrollTop||0,o=t.offsetHeight,r=t.getBoundingClientRect().top-i.getBoundingClientRect().top+n 240 | r+o>s+n?this.scroll(r-s+o,e):r{t.setActiveItemClass(e)})))}inputState(){var t=this 245 | t.control.contains(t.control_input)&&(it(t.control_input,{placeholder:t.settings.placeholder}),t.activeItems.length>0||!t.isFocused&&t.settings.hidePlaceholder&&t.items.length>0?(t.setTextboxValue(),t.isInputHidden=!0):(t.settings.hidePlaceholder&&t.items.length>0&&it(t.control_input,{placeholder:""}),t.isInputHidden=!1),t.wrapper.classList.toggle("input-hidden",t.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var t=this 246 | t.isDisabled||t.isReadOnly||(t.ignoreFocus=!0,t.control_input.offsetWidth?t.control_input.focus():t.focus_node.focus(),setTimeout((()=>{t.ignoreFocus=!1,t.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(t){return this.sifter.getScoreFunction(t,this.getSearchOptions())}getSearchOptions(){var t=this.settings,e=t.sortField 247 | return"string"==typeof t.sortField&&(e=[{field:t.sortField}]),{fields:t.searchField,conjunction:t.searchConjunction,sort:e,nesting:t.nesting}}search(t){var e,i,s=this,n=this.getSearchOptions() 248 | if(s.settings.score&&"function"!=typeof(i=s.settings.score.call(s,t)))throw new Error('Tom Select "score" setting must be a function that returns a function') 249 | return t!==s.lastQuery?(s.lastQuery=t,e=s.sifter.search(t,Object.assign(n,{score:i})),s.currentResults=e):e=Object.assign({},s.currentResults),s.settings.hideSelected&&(e.items=e.items.filter((t=>{let e=P(t.id) 250 | return!(e&&-1!==s.items.indexOf(e))}))),e}refreshOptions(t=!0){var e,i,s,n,o,r,l,a,c,d 251 | const u={},p=[] 252 | var h=this,g=h.inputValue() 253 | const f=g===h.lastQuery||""==g&&null==h.lastQuery 254 | var m=h.search(g),v=null,y=h.settings.shouldOpen||!1,O=h.dropdown_content 255 | f&&(v=h.activeOption)&&(c=v.closest("[data-group]")),n=m.items.length,"number"==typeof h.settings.maxOptions&&(n=Math.min(n,h.settings.maxOptions)),n>0&&(y=!0) 256 | const b=(t,e)=>{let i=u[t] 257 | if(void 0!==i){let t=p[i] 258 | if(void 0!==t)return[i,t.fragment]}let s=document.createDocumentFragment() 259 | return i=p.length,p.push({fragment:s,order:e,optgroup:t}),[i,s]} 260 | for(e=0;e0&&(d=d.cloneNode(!0),it(d,{id:l.$id+"-clone-"+i,"aria-selected":null}),d.classList.add("ts-cloned"),W(d,"active"),h.activeOption&&h.activeOption.dataset.value==n&&c&&c.dataset.group===o.toString()&&(v=d)),a.appendChild(d),""!=o&&(u[o]=s)}}var w 270 | h.settings.lockOptgroupOrder&&p.sort(((t,e)=>t.order-e.order)),l=document.createDocumentFragment(),B(p,(t=>{let e=t.fragment,i=t.optgroup 271 | if(!e||!e.children.length)return 272 | let s=h.optgroups[i] 273 | if(void 0!==s){let t=document.createDocumentFragment(),i=h.render("optgroup_header",s) 274 | z(t,i),z(t,e) 275 | let n=h.render("optgroup",{group:s,options:t}) 276 | z(l,n)}else z(l,e)})),O.innerHTML="",z(O,l),h.settings.highlight&&(w=O.querySelectorAll("span.highlight"),Array.prototype.forEach.call(w,(function(t){var e=t.parentNode 277 | e.replaceChild(t.firstChild,t),e.normalize()})),m.query.length&&m.tokens.length&&B(m.tokens,(t=>{nt(O,t.regex)}))) 278 | var I=t=>{let e=h.render(t,{input:g}) 279 | return e&&(y=!0,O.insertBefore(e,O.firstChild)),e} 280 | if(h.loading?I("loading"):h.settings.shouldLoad.call(h,g)?0===m.items.length&&I("no_results"):I("not_loading"),(a=h.canCreate(g))&&(d=I("option_create")),h.hasOptions=m.items.length>0||a,y){if(m.items.length>0){if(v||"single"!==h.settings.mode||null==h.items[0]||(v=h.getOption(h.items[0])),!O.contains(v)){let t=0 281 | d&&!h.settings.addPrecedence&&(t=1),v=h.selectable()[t]}}else d&&(v=d) 282 | t&&!h.isOpen&&(h.open(),h.scrollToOption(v,"auto")),h.setActiveOption(v)}else h.clearActiveOption(),t&&h.isOpen&&h.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(t,e=!1){const i=this 283 | if(Array.isArray(t))return i.addOptions(t,e),!1 284 | const s=P(t[i.settings.valueField]) 285 | return null!==s&&!i.options.hasOwnProperty(s)&&(t.$order=t.$order||++i.order,t.$id=i.inputId+"-opt-"+t.$order,i.options[s]=t,i.lastQuery=null,e&&(i.userOptions[s]=e,i.trigger("option_add",s,t)),s)}addOptions(t,e=!1){B(t,(t=>{this.addOption(t,e)}))}registerOption(t){return this.addOption(t)}registerOptionGroup(t){var e=P(t[this.settings.optgroupValueField]) 286 | return null!==e&&(t.$order=t.$order||++this.order,this.optgroups[e]=t,e)}addOptionGroup(t,e){var i 287 | e[this.settings.optgroupValueField]=t,(i=this.registerOptionGroup(e))&&this.trigger("optgroup_add",i,e)}removeOptionGroup(t){this.optgroups.hasOwnProperty(t)&&(delete this.optgroups[t],this.clearCache(),this.trigger("optgroup_remove",t))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(t,e){const i=this 288 | var s,n 289 | const o=P(t),r=P(e[i.settings.valueField]) 290 | if(null===o)return 291 | const l=i.options[o] 292 | if(null==l)return 293 | if("string"!=typeof r)throw new Error("Value must be set in option data") 294 | const a=i.getOption(o),c=i.getItem(o) 295 | if(e.$order=e.$order||l.$order,delete i.options[o],i.uncacheValue(r),i.options[r]=e,a){if(i.dropdown_content.contains(a)){const t=i._render("option",e) 296 | st(a,t),i.activeOption===a&&i.setActiveOption(t)}a.remove()}c&&(-1!==(n=i.items.indexOf(o))&&i.items.splice(n,1,r),s=i._render("item",e),c.classList.contains("active")&&U(s,"active"),st(c,s)),i.lastQuery=null}removeOption(t,e){const i=this 297 | t=$(t),i.uncacheValue(t),delete i.userOptions[t],delete i.options[t],i.lastQuery=null,i.trigger("option_remove",t),i.removeItem(t,e)}clearOptions(t){const e=(t||this.clearFilter).bind(this) 298 | this.loadedSearches={},this.userOptions={},this.clearCache() 299 | const i={} 300 | B(this.options,((t,s)=>{e(t,s)&&(i[s]=t)})),this.options=this.sifter.items=i,this.lastQuery=null,this.trigger("option_clear")}clearFilter(t,e){return this.items.indexOf(e)>=0}getOption(t,e=!1){const i=P(t) 301 | if(null===i)return null 302 | const s=this.options[i] 303 | if(null!=s){if(s.$div)return s.$div 304 | if(e)return this._render("option",s)}return null}getAdjacent(t,e,i="option"){var s 305 | if(!t)return null 306 | s="item"==i?this.controlChildren():this.dropdown_content.querySelectorAll("[data-selectable]") 307 | for(let i=0;i0?s[i+1]:s[i-1] 308 | return null}getItem(t){if("object"==typeof t)return t 309 | var e=P(t) 310 | return null!==e?this.control.querySelector(`[data-value="${M(e)}"]`):null}addItems(t,e){var i=this,s=Array.isArray(t)?t:[t] 311 | const n=(s=s.filter((t=>-1===i.items.indexOf(t))))[s.length-1] 312 | s.forEach((t=>{i.isPending=t!==n,i.addItem(t,e)}))}addItem(t,e){q(this,e?[]:["change","dropdown_close"],(()=>{var i,s 313 | const n=this,o=n.settings.mode,r=P(t) 314 | if((!r||-1===n.items.indexOf(r)||("single"===o&&n.close(),"single"!==o&&n.settings.duplicates))&&null!==r&&n.options.hasOwnProperty(r)&&("single"===o&&n.clear(e),"multi"!==o||!n.isFull())){if(i=n._render("item",n.options[r]),n.control.contains(i)&&(i=i.cloneNode(!0)),s=n.isFull(),n.items.splice(n.caretPos,0,r),n.insertAtCaret(i),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let t=n.getOption(r),e=n.getAdjacent(t,1) 315 | e&&n.setActiveOption(e)}n.isPending||n.settings.closeAfterSelect||n.refreshOptions(n.isFocused&&"single"!==o),0!=n.settings.closeAfterSelect&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",r,i),n.isPending||n.updateOriginalInput({silent:e})}(!n.isPending||!s&&n.isFull())&&(n.inputState(),n.refreshState())}}))}removeItem(t=null,e){const i=this 316 | if(!(t=i.getItem(t)))return 317 | var s,n 318 | const o=t.dataset.value 319 | s=et(t),t.remove(),t.classList.contains("active")&&(n=i.activeItems.indexOf(t),i.activeItems.splice(n,1),W(t,"active")),i.items.splice(s,1),i.lastQuery=null,!i.settings.persist&&i.userOptions.hasOwnProperty(o)&&i.removeOption(o,e),s{}){3===arguments.length&&(e=arguments[2]),"function"!=typeof e&&(e=()=>{}) 320 | var i,s=this,n=s.caretPos 321 | if(t=t||s.inputValue(),!s.canCreate(t))return e(),!1 322 | s.lock() 323 | var o=!1,r=t=>{if(s.unlock(),!t||"object"!=typeof t)return e() 324 | var i=P(t[s.settings.valueField]) 325 | if("string"!=typeof i)return e() 326 | s.setTextboxValue(),s.addOption(t,!0),s.setCaret(n),s.addItem(i),e(t),o=!0} 327 | return i="function"==typeof s.settings.create?s.settings.create.call(this,t,r):{[s.settings.labelField]:t,[s.settings.valueField]:t},o||r(i),!0}refreshItems(){var t=this 328 | t.lastQuery=null,t.isSetup&&t.addItems(t.items),t.updateOriginalInput(),t.refreshState()}refreshState(){const t=this 329 | t.refreshValidityState() 330 | const e=t.isFull(),i=t.isLocked 331 | t.wrapper.classList.toggle("rtl",t.rtl) 332 | const s=t.wrapper.classList 333 | var n 334 | s.toggle("focus",t.isFocused),s.toggle("disabled",t.isDisabled),s.toggle("readonly",t.isReadOnly),s.toggle("required",t.isRequired),s.toggle("invalid",!t.isValid),s.toggle("locked",i),s.toggle("full",e),s.toggle("input-active",t.isFocused&&!t.isInputHidden),s.toggle("dropdown-active",t.isOpen),s.toggle("has-options",(n=t.options,0===Object.keys(n).length)),s.toggle("has-items",t.items.length>0)}refreshValidityState(){var t=this 335 | t.input.validity&&(t.isValid=t.input.validity.valid,t.isInvalid=!t.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(t={}){const e=this 336 | var i,s 337 | const n=e.input.querySelector('option[value=""]') 338 | if(e.is_select_tag){const o=[],r=e.input.querySelectorAll("option:checked").length 339 | function l(t,i,s){return t||(t=K('")),t!=n&&e.input.append(t),o.push(t),(t!=n||r>0)&&(t.selected=!0),t}e.input.querySelectorAll("option:checked").forEach((t=>{t.selected=!1})),0==e.items.length&&"single"==e.settings.mode?l(n,"",""):e.items.forEach((t=>{if(i=e.options[t],s=i[e.settings.labelField]||"",o.includes(i.$option)){l(e.input.querySelector(`option[value="${M(t)}"]:not(:checked)`),t,s)}else i.$option=l(i.$option,t,s)}))}else e.input.value=e.getValue() 340 | e.isSetup&&(t.silent||e.trigger("change",e.getValue()))}open(){var t=this 341 | t.isLocked||t.isOpen||"multi"===t.settings.mode&&t.isFull()||(t.isOpen=!0,it(t.focus_node,{"aria-expanded":"true"}),t.refreshState(),J(t.dropdown,{visibility:"hidden",display:"block"}),t.positionDropdown(),J(t.dropdown,{visibility:"visible",display:"block"}),t.focus(),t.trigger("dropdown_open",t.dropdown))}close(t=!0){var e=this,i=e.isOpen 342 | t&&(e.setTextboxValue(),"single"===e.settings.mode&&e.items.length&&e.inputState()),e.isOpen=!1,it(e.focus_node,{"aria-expanded":"false"}),J(e.dropdown,{display:"none"}),e.settings.hideSelected&&e.clearActiveOption(),e.refreshState(),i&&e.trigger("dropdown_close",e.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var t=this.control,e=t.getBoundingClientRect(),i=t.offsetHeight+e.top+window.scrollY,s=e.left+window.scrollX 343 | J(this.dropdown,{width:e.width+"px",top:i+"px",left:s+"px"})}}clear(t){var e=this 344 | if(e.items.length){var i=e.controlChildren() 345 | B(i,(t=>{e.removeItem(t,!0)})),e.inputState(),t||e.updateOriginalInput(),e.trigger("clear")}}insertAtCaret(t){const e=this,i=e.caretPos,s=e.control 346 | s.insertBefore(t,s.children[i]||null),e.setCaret(i+1)}deleteSelection(t){var e,i,s,n,o,r=this 347 | e=t&&8===t.keyCode?-1:1,i={start:(o=r.control_input).selectionStart||0,length:(o.selectionEnd||0)-(o.selectionStart||0)} 348 | const l=[] 349 | if(r.activeItems.length)n=tt(r.activeItems,e),s=et(n),e>0&&s++,B(r.activeItems,(t=>l.push(t))) 350 | else if((r.isFocused||"single"===r.settings.mode)&&r.items.length){const t=r.controlChildren() 351 | let s 352 | e<0&&0===i.start&&0===i.length?s=t[r.caretPos-1]:e>0&&i.start===r.inputValue().length&&(s=t[r.caretPos]),void 0!==s&&l.push(s)}if(!r.shouldDelete(l,t))return!1 353 | for(D(t,!0),void 0!==s&&r.setCaret(s);l.length;)r.removeItem(l.pop()) 354 | return r.inputState(),r.positionDropdown(),r.refreshOptions(!1),!0}shouldDelete(t,e){const i=t.map((t=>t.dataset.value)) 355 | return!(!i.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(i,e))}advanceSelection(t,e){var i,s,n=this 356 | n.rtl&&(t*=-1),n.inputValue().length||(H(ot,e)||H("shiftKey",e)?(s=(i=n.getLastActive(t))?i.classList.contains("active")?n.getAdjacent(i,t,"item"):i:t>0?n.control_input.nextElementSibling:n.control_input.previousElementSibling)&&(s.classList.contains("active")&&n.removeActiveItem(i),n.setActiveItemClass(s)):n.moveCaret(t))}moveCaret(t){}getLastActive(t){let e=this.control.querySelector(".last-active") 357 | if(e)return e 358 | var i=this.control.querySelectorAll(".active") 359 | return i?tt(i,t):void 0}setCaret(t){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(t=this.isReadOnly||this.isDisabled){this.isLocked=t,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(t){this.focus_node.tabIndex=t?-1:this.tabIndex,this.isDisabled=t,this.input.disabled=t,this.control_input.disabled=t,this.setLocked()}setReadOnly(t){this.isReadOnly=t,this.input.readOnly=t,this.control_input.readOnly=t,this.setLocked()}destroy(){var t=this,e=t.revertSettings 360 | t.trigger("destroy"),t.off(),t.wrapper.remove(),t.dropdown.remove(),t.input.innerHTML=e.innerHTML,t.input.tabIndex=e.tabIndex,W(t.input,"tomselected","ts-hidden-accessible"),t._destroy(),delete t.input.tomselect}render(t,e){var i,s 361 | const n=this 362 | if("function"!=typeof this.settings.render[t])return null 363 | if(!(s=n.settings.render[t].call(this,e,V)))return null 364 | if(s=K(s),"option"===t||"option_create"===t?e[n.settings.disabledField]?it(s,{"aria-disabled":"true"}):it(s,{"data-selectable":""}):"optgroup"===t&&(i=e.group[n.settings.optgroupValueField],it(s,{"data-group":i}),e.group[n.settings.disabledField]&&it(s,{"data-disabled":""})),"option"===t||"item"===t){const i=$(e[n.settings.valueField]) 365 | it(s,{"data-value":i}),"item"===t?(U(s,n.settings.itemClass),it(s,{"data-ts-item":""})):(U(s,n.settings.optionClass),it(s,{role:"option",id:e.$id}),e.$div=s,n.options[i]=e)}return s}_render(t,e){const i=this.render(t,e) 366 | if(null==i)throw"HTMLElement expected" 367 | return i}clearCache(){B(this.options,(t=>{t.$div&&(t.$div.remove(),delete t.$div)}))}uncacheValue(t){const e=this.getOption(t) 368 | e&&e.remove()}canCreate(t){return this.settings.create&&t.length>0&&this.settings.createFilter.call(this,t)}hook(t,e,i){var s=this,n=s[e] 369 | s[e]=function(){var e,o 370 | return"after"===t&&(e=n.apply(s,arguments)),o=i.apply(s,arguments),"instead"===t?o:("before"===t&&(e=n.apply(s,arguments)),e)}}}return ct})) 371 | var tomSelect=function(t,e){return new TomSelect(t,e)} 372 | //# sourceMappingURL=tom-select.base.min.js.map 373 | --------------------------------------------------------------------------------